diff --git a/.forgejo/workflows/build-docker.yml b/.forgejo/workflows/build-docker.yml index 91ac6b3..e24bcf2 100644 --- a/.forgejo/workflows/build-docker.yml +++ b/.forgejo/workflows/build-docker.yml @@ -5,11 +5,11 @@ on: push: - tags: - - 'v*' + branches: + - master jobs: - docker: + basic: runs-on: any steps: - name: Checkout code @@ -45,6 +45,15 @@ jobs: push: true pull: true tags: git.ronmi.tw/ronmi/forgejo-pages:arm64,ronmi/forgejo-pages:arm64 + - name: Build arm64 image (git) + uses: docker/build-push-action@v6 + with: + context: . + file: git.dockerfile + platforms: linux/arm64 + push: true + pull: true + tags: git.ronmi.tw/ronmi/forgejo-pages:git-arm64,ronmi/forgejo-pages:git-arm64 - name: Build amd64 binary run: GOARCH=amd64 go build - name: Build amd64 image @@ -55,14 +64,24 @@ jobs: push: true pull: true tags: git.ronmi.tw/ronmi/forgejo-pages:amd64,ronmi/forgejo-pages:amd64 + - name: Build amd64 image (git) + uses: docker/build-push-action@v6 + with: + context: . + file: git.dockerfile + platforms: linux/amd64 + push: true + pull: true + tags: git.ronmi.tw/ronmi/forgejo-pages:git-amd64,ronmi/forgejo-pages:git-amd64 - name: Create multiarch image run: | docker buildx imagetools create -t git.ronmi.tw/ronmi/forgejo-pages git.ronmi.tw/ronmi/forgejo-pages:arm64 git.ronmi.tw/ronmi/forgejo-pages:amd64 docker buildx imagetools create -t ronmi/forgejo-pages ronmi/forgejo-pages:arm64 ronmi/forgejo-pages:amd64 + docker buildx imagetools create -t git.ronmi.tw/ronmi/forgejo-pages:git git.ronmi.tw/ronmi/forgejo-pages:git-arm64 git.ronmi.tw/ronmi/forgejo-pages:git-amd64 + docker buildx imagetools create -t ronmi/forgejo-pages:git ronmi/forgejo-pages:git-arm64 ronmi/forgejo-pages:git-amd64 - name: Update readme to docker hub uses: https://github.com/peter-evans/dockerhub-description@v4 with: username: ${{ secrets.DOCKER_HUB_USER }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} repository: ronmi/forgejo-pages - diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..194951c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +forgejo-pages diff --git a/README.md b/README.md index 8c628cd..e91b1f1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This mode is good for simple setup, like, you have small number of viewers, or y ### Webhook mode -Webhook mode is a tool which helps you to download latest content via git. You'll have to setup a webhook in forgejo server in order to notify it when to download new content. +Webhook mode is a tool which helps you to download latest content via git. You'll have to setup a webhook in forgejo server in order to notify it when to download new content. You have to have `git` binary in your path. To serve downloaded pages, you'll have to use a web server like Nginx. @@ -41,9 +41,13 @@ Take care about permissions of the API token. For serve mode, repositories the k ``` docker run -p 8080:8080 --user 1000:1000 ronmi/forgejo-pages serve -s https://git.example.com -k my-secret-token -docker run -p 8080:8080 --user 1000:1000 -v `pwd`/data:/data ronmi/forgejo-pages listen -u myuser -k my-secret-token -s https://git.example.com -a :8080 -b static-pages -d /data +docker run -p 8080:8080 --user 1000:1000 -v `pwd`/data:/data ronmi/forgejo-pages:git listen -u myuser -k my-secret-token -s https://git.example.com -a :8080 -b static-pages -d /data ``` +Serve mode could use `latest` tag, a minimal image contains only libc, CA certificates, timezone data and page server binary. + +Webhook mode should use `git` tag. + # FAQ ### Can I use user.example.com/repo/path format? diff --git a/cmd/listen.go b/cmd/listen.go index e079383..4da2bca 100644 --- a/cmd/listen.go +++ b/cmd/listen.go @@ -15,6 +15,8 @@ import ( "time" "git.ronmi.tw/ronmi/forgejo-pages/lib" + "github.com/raohwork/task" + "github.com/raohwork/task/httptask" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -68,7 +70,7 @@ is up to you, eg. Nginx, Apache, or even a simple Go server. GitPass: token, } - s, err := lib.UseWebhook(bind, cfg) + s, r, err := lib.UseWebhook(bind, cfg) if err != nil { fmt.Println("cannot create server: ", err) return @@ -83,14 +85,11 @@ is up to you, eg. Nginx, Apache, or even a simple Go server. defer stop() fmt.Println("starting server") - go func() { - <-ctx.Done() - stop() - ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second) - defer cancel() - s.Shutdown(ctx) - }() - s.ListenAndServe() + task.Wait( + httptask.Server(s, task.Timeout(10*time.Second)), + r.Run, + ).Run(ctx) + }, } diff --git a/cmd/serve.go b/cmd/serve.go index 7970908..c400476 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -14,6 +14,8 @@ import ( "time" "git.ronmi.tw/ronmi/forgejo-pages/lib" + "github.com/raohwork/task" + "github.com/raohwork/task/httptask" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -70,14 +72,7 @@ cache/protection layer like Cloudflare in front of the server. defer stop() fmt.Println("starting server") - go func() { - <-ctx.Done() - stop() - ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second) - defer cancel() - s.Shutdown(ctx) - }() - s.ListenAndServe() + httptask.Server(s, task.Timeout(10*time.Second)).Run(ctx) }, } diff --git a/git.dockerfile b/git.dockerfile new file mode 100644 index 0000000..4259929 --- /dev/null +++ b/git.dockerfile @@ -0,0 +1,13 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. + +FROM debian:stable-slim + +# Install git +RUN apt-get update && apt-get install -y \ + ca-certificates git tzdata \ + && apt-get clean -y && rm -rf /var/lib/apt/lists/* + +ADD forgejo-pages /usr/bin/forgejo-pages +ENTRYPOINT ["/usr/bin/forgejo-pages"] diff --git a/go.mod b/go.mod index 640e949..a963e88 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.ronmi.tw/ronmi/forgejo-pages go 1.21.6 require ( + github.com/raohwork/task v0.3.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 ) diff --git a/go.sum b/go.sum index aba9163..c0b39f8 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/raohwork/task v0.3.0 h1:4j0jT1a+f5O+g6q22o42sFEdSlYGYtgUHLbg3cF/7VE= +github.com/raohwork/task v0.3.0/go.mod h1:QkVxY/Q/w6bW5Xjhcp8vn5qLgoS+70jUTyGueI0nzMo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/lib/git.go b/lib/git.go new file mode 100644 index 0000000..5c03fe6 --- /dev/null +++ b/lib/git.go @@ -0,0 +1,159 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package lib + +import ( + "context" + "fmt" + "net/url" + "os" + "os/exec" + "path" + "path/filepath" + "sync" +) + +type repoSpec struct { + user string + name string +} + +type GitRunner struct { + ch <-chan repoSpec + cfg *WebhookCFG +} + +func (g *GitRunner) Run(ctx context.Context) error { + // graceful: exit only if all tasks are done + for { + select { + case repo := <-g.ch: + err := g.cfg.download(repo.user, repo.name) + if err != nil { + fmt.Println("Failed to download repo: ", err) + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (c *WebhookCFG) pageDir(user, repo string) string { + return filepath.Join(c.PageDir, user, repo) +} + +func (c *WebhookCFG) gitDir(user, repo string) string { + return filepath.Join(c.GitDir, user, repo) +} + +func (c *WebhookCFG) checkout(user, repo string) (err error) { + pageDir := c.pageDir(user, repo) + gitDir := c.gitDir(user, repo) + + git := exec.Command( + "git", + "--git-dir", gitDir, + "--work-tree", pageDir, + "checkout", "origin/"+c.Branch, + ) + output, err := git.CombinedOutput() + if err != nil { + fmt.Println("git checkout failed: ", err) + fmt.Println("=========== Dump output: ============") + fmt.Println(string(output)) + fmt.Println("=====================================") + } + + return +} + +func (c *WebhookCFG) fetch(user, repo string) (err error) { + gitDir := c.gitDir(user, repo) + git := exec.Command( + "git", + "--git-dir", gitDir, + "fetch", "origin", c.Branch, + ) + output, err := git.CombinedOutput() + if err != nil { + fmt.Println("git fetch failed: ", err) + fmt.Println("=========== Dump output: ============") + fmt.Println(string(output)) + fmt.Println("=====================================") + } + + return +} + +func (c *WebhookCFG) clone(user, repo string) (err error) { + pageDir := c.pageDir(user, repo) + err = os.MkdirAll(filepath.Dir(pageDir), 0755) + if err != nil { + return + } + + gitDir := c.gitDir(user, repo) + err = os.MkdirAll(filepath.Dir(gitDir), 0700) + if err != nil { + return + } + + uri := c.Server + uri.Path = "/" + path.Join(user, repo) + ".git" + uri.User = url.UserPassword(c.GitUser, c.GitPass) + + git := exec.Command( + "git", + "clone", + "-b", c.Branch, + "--single-branch", + "--no-tags", + "--separate-git-dir", gitDir, + uri.String(), + pageDir, + ) + output, err := git.CombinedOutput() + if err != nil { + fmt.Println("git clone failed: ", err) + fmt.Println("=========== Dump output: ============") + fmt.Println(string(output)) + fmt.Println("=====================================") + } + + os.Remove(filepath.Join(pageDir, ".git")) + + return +} + +var repoLock = &sync.Map{} + +func (c *WebhookCFG) lock(user, repo string) func() { + key := user + "/" + repo + + lock, _ := repoLock.LoadOrStore(key, &sync.Mutex{}) + m := lock.(*sync.Mutex) + m.Lock() + return m.Unlock +} + +func (c *WebhookCFG) download(user, repo string) (err error) { + unlock := c.lock(user, repo) + defer unlock() + fmt.Println("Pulling ", user, repo) + + gitDir := c.gitDir(user, repo) + if _, err = os.Stat(gitDir); os.IsNotExist(err) { + err = c.clone(user, repo) + } else { + err = c.fetch(user, repo) + } + + if err != nil { + return + } + + err = c.checkout(user, repo) + return +} diff --git a/lib/hook.go b/lib/hook.go index b0b6bb4..fe0d592 100644 --- a/lib/hook.go +++ b/lib/hook.go @@ -10,12 +10,7 @@ import ( "fmt" "io" "net/http" - "net/url" - "os" - "os/exec" "path" - "path/filepath" - "sync" ) type WebhookCFG struct { @@ -24,123 +19,7 @@ type WebhookCFG struct { GitDir string GitUser string GitPass string -} - -func (c *WebhookCFG) pageDir(user, repo string) string { - return filepath.Join(c.PageDir, user, repo) -} - -func (c *WebhookCFG) gitDir(user, repo string) string { - return filepath.Join(c.GitDir, user, repo) -} - -func (c *WebhookCFG) checkout(user, repo string) (err error) { - pageDir := c.pageDir(user, repo) - gitDir := c.gitDir(user, repo) - - git := exec.Command( - "git", - "--git-dir", gitDir, - "--work-tree", pageDir, - "checkout", "origin/"+c.Branch, - ) - output, err := git.CombinedOutput() - if err != nil { - fmt.Println("git checkout failed: ", err) - fmt.Println("=========== Dump output: ============") - fmt.Println(string(output)) - fmt.Println("=====================================") - } - - return -} - -func (c *WebhookCFG) fetch(user, repo string) (err error) { - gitDir := c.gitDir(user, repo) - git := exec.Command( - "git", - "--git-dir", gitDir, - "fetch", "origin", c.Branch, - ) - output, err := git.CombinedOutput() - if err != nil { - fmt.Println("git fetch failed: ", err) - fmt.Println("=========== Dump output: ============") - fmt.Println(string(output)) - fmt.Println("=====================================") - } - - return -} - -func (c *WebhookCFG) clone(user, repo string) (err error) { - pageDir := c.pageDir(user, repo) - err = os.MkdirAll(filepath.Dir(pageDir), 0755) - if err != nil { - return - } - - gitDir := c.gitDir(user, repo) - err = os.MkdirAll(filepath.Dir(gitDir), 0700) - if err != nil { - return - } - - uri := c.Server - uri.Path = "/" + path.Join(user, repo) + ".git" - uri.User = url.UserPassword(c.GitUser, c.GitPass) - - git := exec.Command( - "git", - "clone", - "-b", c.Branch, - "--single-branch", - "--no-tags", - "--separate-git-dir", gitDir, - uri.String(), - pageDir, - ) - output, err := git.CombinedOutput() - if err != nil { - fmt.Println("git clone failed: ", err) - fmt.Println("=========== Dump output: ============") - fmt.Println(string(output)) - fmt.Println("=====================================") - } - - os.Remove(filepath.Join(pageDir, ".git")) - - return -} - -var repoLock = &sync.Map{} - -func (c *WebhookCFG) lock(user, repo string) func() { - key := user + "/" + repo - - lock, _ := repoLock.LoadOrStore(key, &sync.Mutex{}) - m := lock.(*sync.Mutex) - m.Lock() - return m.Unlock -} - -func (c *WebhookCFG) download(user, repo string) (err error) { - unlock := c.lock(user, repo) - defer unlock() - - gitDir := c.gitDir(user, repo) - if _, err = os.Stat(gitDir); os.IsNotExist(err) { - err = c.clone(user, repo) - } else { - err = c.fetch(user, repo) - } - - if err != nil { - return - } - - err = c.checkout(user, repo) - return + ch chan<- repoSpec } type webhookPayload struct { @@ -185,22 +64,19 @@ func (c *WebhookCFG) handle(w http.ResponseWriter, r *http.Request) { user, repo := path.Split(payload.Repository.FullName) user = user[:len(user)-1] go func() { - fmt.Println("Pulling ", user, repo) - err = c.download(user, repo) - if err != nil { - fmt.Println("Failed to download repository: ", err) - return - } + c.ch <- repoSpec{user: user, name: repo} }() } -func UseWebhook(bind string, cfg *WebhookCFG) (*http.Server, error) { +func UseWebhook(bind string, cfg *WebhookCFG) (*http.Server, *GitRunner, error) { if cfg == nil { - return nil, errors.New("webhook config is nil") + return nil, nil, errors.New("webhook config is nil") } s := &http.Server{ Addr: bind, Handler: http.HandlerFunc(cfg.handle), } - return s, nil + ch := make(chan repoSpec, 5) + cfg.ch = ch + return s, &GitRunner{ch: ch, cfg: cfg}, nil } diff --git a/lib/web.go b/lib/web.go index 5616a9f..ab0da19 100644 --- a/lib/web.go +++ b/lib/web.go @@ -12,6 +12,8 @@ import ( "net/http" "net/url" "strings" + "mime" + "path/filepath" ) type Forgejo struct { @@ -34,7 +36,7 @@ func (f *Forgejo) GetFile(ctx context.Context, headers map[string]string, user, if err != nil { return } - + for k, v := range headers { if v == "" { continue @@ -50,7 +52,7 @@ func (f *Forgejo) GetFile(ctx context.Context, headers map[string]string, user, err = ErrNotFound return } - + return resp, nil } @@ -102,24 +104,43 @@ func (f *Forgejo) handle(w http.ResponseWriter, r *http.Request) { } file = strings.Join(arr[2:], "/") } - + headers := map[string]string{} headers["If-None-Match"] = r.Header.Get("If-None-Match") headers["If-Modified-Since"] = r.Header.Get("If-Modified-Since") headers["If-Range"] = r.Header.Get("If-Range") headers["Range"] = r.Header.Get("Range") + headers["X-Forwarded-For"] = r.Header.Get("X-Forwarded-For") + headers["X-Forwarded-Host"] = r.Header.Get("X-Forwarded-Host") + headers["X-Forwarded-Proto"] = r.Header.Get("X-Forwarded-Proto") + headers["X-Real-IP"] = r.Header.Get("X-Real-IP") + headers["X-Host"] = r.Header.Get("X-Host") + headers["CF-Connecting-IP"] = r.Header.Get("CF-Connecting-IP") + headers["CF-IPCountry"] = r.Header.Get("CF-IPCountry") + headers["CF-Visitor"] = r.Header.Get("CF-Visitor") + headers["CF-Request-ID"] = r.Header.Get("CF-Request-ID") + headers["CF-Ray"] = r.Header.Get("CF-Ray") resp, err := f.GetFile(r.Context(), headers, user, repo, f.Branch, file) if err != nil { w.WriteHeader(http.StatusNotFound) return } defer resp.Body.Close() - + trySet(w.Header(), "Etag", resp.Header) trySet(w.Header(), "Last-Modified", resp.Header) trySet(w.Header(), "Content-Length", resp.Header) trySet(w.Header(), "Content-Range", resp.Header) - + contentType := resp.Header.Get("Content-Type") + if contentType == "" || strings.HasPrefix(contentType, "text/plain") { + if ct := mime.TypeByExtension(filepath.Ext(file)); ct != "" { + contentType = ct + } + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) }