Compare commits

...

10 commits

Author SHA1 Message Date
857eee205c Infer file mime type
Some checks failed
/ basic (push) Failing after 2m34s
2026-05-15 09:58:28 -03:00
Ronmi Ren
2b1c2a3c46 forward proxy/cloudflare headers to api 2025-01-13 18:03:17 +08:00
Ronmi Ren
6f7994bfed y no async? socks 2025-01-13 16:54:09 +08:00
Ronmi Ren
61b5749682 build on push 2025-01-13 16:51:49 +08:00
Ronmi Ren
bd98d78b8b update readme 2025-01-13 16:51:07 +08:00
Ronmi Ren
a930028b33 forget to install tzdata for git image 2025-01-13 16:49:29 +08:00
Ronmi Ren
84a56864c9 update deps 2025-01-13 16:43:47 +08:00
Ronmi Ren
e1319660d9 build git image 2025-01-13 16:43:00 +08:00
Ronmi Ren
6bae5d11c2 implement graceful shutdown to webhook mode
ensures all pending tasks are finished before quit.
2025-01-13 16:39:06 +08:00
Ronmi Ren
a10a04e1ce apply task package 2025-01-13 16:18:52 +08:00
11 changed files with 249 additions and 159 deletions

View file

@ -5,11 +5,11 @@
on: on:
push: push:
tags: branches:
- 'v*' - master
jobs: jobs:
docker: basic:
runs-on: any runs-on: any
steps: steps:
- name: Checkout code - name: Checkout code
@ -45,6 +45,15 @@ jobs:
push: true push: true
pull: true pull: true
tags: git.ronmi.tw/ronmi/forgejo-pages:arm64,ronmi/forgejo-pages:arm64 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 - name: Build amd64 binary
run: GOARCH=amd64 go build run: GOARCH=amd64 go build
- name: Build amd64 image - name: Build amd64 image
@ -55,14 +64,24 @@ jobs:
push: true push: true
pull: true pull: true
tags: git.ronmi.tw/ronmi/forgejo-pages:amd64,ronmi/forgejo-pages:amd64 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 - name: Create multiarch image
run: | 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 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 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 - name: Update readme to docker hub
uses: https://github.com/peter-evans/dockerhub-description@v4 uses: https://github.com/peter-evans/dockerhub-description@v4
with: with:
username: ${{ secrets.DOCKER_HUB_USER }} username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }} password: ${{ secrets.DOCKER_HUB_PASSWORD }}
repository: ronmi/forgejo-pages repository: ronmi/forgejo-pages

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
forgejo-pages

View file

@ -14,7 +14,7 @@ This mode is good for simple setup, like, you have small number of viewers, or y
### Webhook mode ### 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. 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 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 # FAQ
### Can I use user.example.com/repo/path format? ### Can I use user.example.com/repo/path format?

View file

@ -15,6 +15,8 @@ import (
"time" "time"
"git.ronmi.tw/ronmi/forgejo-pages/lib" "git.ronmi.tw/ronmi/forgejo-pages/lib"
"github.com/raohwork/task"
"github.com/raohwork/task/httptask"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -68,7 +70,7 @@ is up to you, eg. Nginx, Apache, or even a simple Go server.
GitPass: token, GitPass: token,
} }
s, err := lib.UseWebhook(bind, cfg) s, r, err := lib.UseWebhook(bind, cfg)
if err != nil { if err != nil {
fmt.Println("cannot create server: ", err) fmt.Println("cannot create server: ", err)
return return
@ -83,14 +85,11 @@ is up to you, eg. Nginx, Apache, or even a simple Go server.
defer stop() defer stop()
fmt.Println("starting server") fmt.Println("starting server")
go func() { task.Wait(
<-ctx.Done() httptask.Server(s, task.Timeout(10*time.Second)),
stop() r.Run,
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second) ).Run(ctx)
defer cancel()
s.Shutdown(ctx)
}()
s.ListenAndServe()
}, },
} }

View file

@ -14,6 +14,8 @@ import (
"time" "time"
"git.ronmi.tw/ronmi/forgejo-pages/lib" "git.ronmi.tw/ronmi/forgejo-pages/lib"
"github.com/raohwork/task"
"github.com/raohwork/task/httptask"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -70,14 +72,7 @@ cache/protection layer like Cloudflare in front of the server.
defer stop() defer stop()
fmt.Println("starting server") fmt.Println("starting server")
go func() { httptask.Server(s, task.Timeout(10*time.Second)).Run(ctx)
<-ctx.Done()
stop()
ctx, cancel := context.WithTimeout(context.TODO(), 5*time.Second)
defer cancel()
s.Shutdown(ctx)
}()
s.ListenAndServe()
}, },
} }

13
git.dockerfile Normal file
View file

@ -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"]

1
go.mod
View file

@ -3,6 +3,7 @@ module git.ronmi.tw/ronmi/forgejo-pages
go 1.21.6 go 1.21.6
require ( require (
github.com/raohwork/task v0.3.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
) )

2
go.sum
View file

@ -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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

159
lib/git.go Normal file
View file

@ -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
}

View file

@ -10,12 +10,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os"
"os/exec"
"path" "path"
"path/filepath"
"sync"
) )
type WebhookCFG struct { type WebhookCFG struct {
@ -24,123 +19,7 @@ type WebhookCFG struct {
GitDir string GitDir string
GitUser string GitUser string
GitPass string GitPass string
} ch chan<- repoSpec
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
} }
type webhookPayload struct { 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, repo := path.Split(payload.Repository.FullName)
user = user[:len(user)-1] user = user[:len(user)-1]
go func() { go func() {
fmt.Println("Pulling ", user, repo) c.ch <- repoSpec{user: user, name: repo}
err = c.download(user, repo)
if err != nil {
fmt.Println("Failed to download repository: ", err)
return
}
}() }()
} }
func UseWebhook(bind string, cfg *WebhookCFG) (*http.Server, error) { func UseWebhook(bind string, cfg *WebhookCFG) (*http.Server, *GitRunner, error) {
if cfg == nil { if cfg == nil {
return nil, errors.New("webhook config is nil") return nil, nil, errors.New("webhook config is nil")
} }
s := &http.Server{ s := &http.Server{
Addr: bind, Addr: bind,
Handler: http.HandlerFunc(cfg.handle), Handler: http.HandlerFunc(cfg.handle),
} }
return s, nil ch := make(chan repoSpec, 5)
cfg.ch = ch
return s, &GitRunner{ch: ch, cfg: cfg}, nil
} }

View file

@ -12,6 +12,8 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"mime"
"path/filepath"
) )
type Forgejo struct { type Forgejo struct {
@ -34,7 +36,7 @@ func (f *Forgejo) GetFile(ctx context.Context, headers map[string]string, user,
if err != nil { if err != nil {
return return
} }
for k, v := range headers { for k, v := range headers {
if v == "" { if v == "" {
continue continue
@ -50,7 +52,7 @@ func (f *Forgejo) GetFile(ctx context.Context, headers map[string]string, user,
err = ErrNotFound err = ErrNotFound
return return
} }
return resp, nil return resp, nil
} }
@ -102,24 +104,43 @@ func (f *Forgejo) handle(w http.ResponseWriter, r *http.Request) {
} }
file = strings.Join(arr[2:], "/") file = strings.Join(arr[2:], "/")
} }
headers := map[string]string{} headers := map[string]string{}
headers["If-None-Match"] = r.Header.Get("If-None-Match") headers["If-None-Match"] = r.Header.Get("If-None-Match")
headers["If-Modified-Since"] = r.Header.Get("If-Modified-Since") headers["If-Modified-Since"] = r.Header.Get("If-Modified-Since")
headers["If-Range"] = r.Header.Get("If-Range") headers["If-Range"] = r.Header.Get("If-Range")
headers["Range"] = r.Header.Get("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) resp, err := f.GetFile(r.Context(), headers, user, repo, f.Branch, file)
if err != nil { if err != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
trySet(w.Header(), "Etag", resp.Header) trySet(w.Header(), "Etag", resp.Header)
trySet(w.Header(), "Last-Modified", resp.Header) trySet(w.Header(), "Last-Modified", resp.Header)
trySet(w.Header(), "Content-Length", resp.Header) trySet(w.Header(), "Content-Length", resp.Header)
trySet(w.Header(), "Content-Range", 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) w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) io.Copy(w, resp.Body)
} }