first commit

This commit is contained in:
2025-12-30 00:36:45 +04:00
commit ef2dd72d0d
6 changed files with 418 additions and 0 deletions

28
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
aykhans/gopkg-proxy:${{ env.SHORT_SHA }}
aykhans/gopkg-proxy:latest

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM golang:1.25.5-alpine AS builder
WORKDIR /app
RUN --mount=type=bind,target=. CGO_ENABLED=0 go build -ldflags="-s -w" -o /server .
FROM scratch
COPY --from=builder /server /server
EXPOSE 8421
ENTRYPOINT ["/server"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Aykhan Shahsuvarov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# gopkg-proxy
A lightweight vanity import path server for Go packages. Use your own domain for clean, branded package imports.
## Configuration
### Add Packages
Edit `main.go`:
```go
var packages = []Package{
{
Path: "/my-package",
Repo: "https://github.com/username/my-package",
VCS: "git",
},
}
```
### Environment Variables
- `PORT` - Server port (default: `8421`)
- `HOST_HEADER` - Header to read domain from (default: `X-Forwarded-Host`)
## Usage
Instead of:
```go
import "github.com/aykhans/go-utils"
```
Use:
```go
import "gopkg.yourdomain.com/go-utils"
```

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module gopkg-proxy
go 1.25.5

315
main.go Normal file
View File

@@ -0,0 +1,315 @@
package main
import (
"html/template"
"log"
"net/http"
"os"
)
// Package represents a Go package mapping
type Package struct {
Path string // URL path like "/go-utils"
Repo string
VCS string // "git", "hg", etc.
}
// Package mappings
var packages = []Package{
{
Path: "/go-utils",
Repo: "https://github.com/aykhans/go-utils",
VCS: "git",
},
{
Path: "/sarin",
Repo: "https://github.com/aykhans/sarin",
VCS: "git",
},
}
type HomeData struct {
Domain string
Packages []Package
Count int
}
type VanityData struct {
Domain string
Path string
Repo string
VCS string
}
type RedirectData struct {
Domain string
Path string
}
var hostHeader = getEnvOrDefault("HOST_HEADER", "X-Forwarded-Host")
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getDomain extracts the domain from the request
func getDomain(r *http.Request) string {
if host := r.Header.Get(hostHeader); host != "" {
return host
}
return r.Host
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
data := HomeData{
Domain: getDomain(r),
Packages: packages,
Count: len(packages),
}
tmpl, err := template.New("home").Parse(homeTemplate)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("Template execution error: %v", err)
}
}
func packageHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Find package by path
var pkg *Package
for i := range packages {
if packages[i].Path == path {
pkg = &packages[i]
break
}
}
if pkg == nil {
http.NotFound(w, r)
return
}
// Check if this is a go-get request
if r.URL.Query().Get("go-get") == "1" {
// Respond with meta tags for go get
data := VanityData{
Domain: getDomain(r),
Path: pkg.Path,
Repo: pkg.Repo,
VCS: pkg.VCS,
}
tmpl, err := template.New("vanity").Parse(vanityTemplate)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("Template execution error: %v", err)
}
} else {
// Regular browser request - redirect to pkg.go.dev
data := RedirectData{
Domain: getDomain(r),
Path: pkg.Path,
}
tmpl, err := template.New("redirect").Parse(redirectTemplate)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("Template execution error: %v", err)
}
}
}
func main() {
// Home page
http.HandleFunc("/{$}", homeHandler)
// All package paths
http.HandleFunc("/", packageHandler)
port := getEnvOrDefault("PORT", "8421")
log.Printf("Server listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
const vanityTemplate = `<!DOCTYPE html>
<html>
<head>
<meta name="go-import" content="{{.Domain}}{{.Path}} {{.VCS}} {{.Repo}}">
<meta name="go-source" content="{{.Domain}}{{.Path}} {{.Repo}} {{.Repo}}/tree/master{/dir} {{.Repo}}/blob/master{/dir}/{file}#L{line}">
</head>
<body>
go get {{.Domain}}{{.Path}}
</body>
</html>`
const redirectTemplate = `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0; url=https://pkg.go.dev/{{.Domain}}{{.Path}}">
</head>
<body>
Redirecting to <a href="https://pkg.go.dev/{{.Domain}}{{.Path}}">pkg.go.dev/{{.Domain}}{{.Path}}</a>...
</body>
</html>`
const homeTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Go Packages - {{.Domain}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
line-height: 1.6;
color: #333;
}
h1 {
color: #00ADD8;
border-bottom: 2px solid #00ADD8;
padding-bottom: 10px;
}
.package {
background: #f5f5f5;
border-left: 4px solid #00ADD8;
padding: 15px;
margin: 15px 0;
border-radius: 4px;
}
.package-name {
font-family: monospace;
font-size: 1.1em;
color: #00ADD8;
font-weight: bold;
}
.package-repo {
margin-top: 5px;
font-size: 0.9em;
}
.package-repo a {
color: #666;
text-decoration: none;
}
.package-repo a:hover {
text-decoration: underline;
}
.install-cmd-wrapper {
position: relative;
margin-top: 8px;
display: flex;
align-items: center;
}
.install-cmd {
background: #2d2d2d;
color: #f8f8f2;
padding: 10px;
padding-right: 45px;
border-radius: 4px;
font-family: monospace;
overflow-x: auto;
flex: 1;
}
.copy-btn {
position: absolute;
right: 8px;
background: #444;
border: none;
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
color: #f8f8f2;
font-size: 14px;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
height: 28px;
}
.copy-btn:hover {
background: #555;
}
.copy-btn:active {
background: #00ADD8;
}
.copy-btn.copied {
background: #00ADD8;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ddd;
text-align: center;
color: #666;
font-size: 0.9em;
}
</style>
</head>
<body>
<h1>Go Packages</h1>
{{range $index, $pkg := .Packages}}
<div class="package">
<div class="package-name">{{$.Domain}}{{$pkg.Path}}</div>
<div class="package-repo">
Source: <a href="{{$pkg.Repo}}" target="_blank">{{$pkg.Repo}}</a>
</div>
<div class="install-cmd-wrapper">
<div class="install-cmd" id="cmd-{{$index}}">go get {{$.Domain}}{{$pkg.Path}}</div>
<button class="copy-btn" onclick="copyToClipboard('cmd-{{$index}}', this)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 5.5v7a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5v-7A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5z" stroke="currentColor" stroke-width="1.5" fill="none"/>
<path d="M5 4V3.5A1.5 1.5 0 0 1 6.5 2h7A1.5 1.5 0 0 1 15 3.5v7a1.5 1.5 0 0 1-1.5 1.5H13" stroke="currentColor" stroke-width="1.5" fill="none"/>
</svg>
</button>
</div>
</div>
{{end}}
<div class="footer">
<p>Total packages: {{.Count}}</p>
</div>
<script>
function copyToClipboard(elementId, button) {
const element = document.getElementById(elementId);
const text = element.textContent;
navigator.clipboard.writeText(text).then(() => {
// Visual feedback
button.classList.add('copied');
const originalHTML = button.innerHTML;
button.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13 4L6 11L3 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
setTimeout(() => {
button.classList.remove('copied');
button.innerHTML = originalHTML;
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
</script>
</body>
</html>`