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:
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

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 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?

View file

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

View file

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

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
require (
github.com/raohwork/task v0.3.0
github.com/spf13/cobra v1.8.1
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.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=

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

View file

@ -12,6 +12,8 @@ import (
"net/http"
"net/url"
"strings"
"mime"
"path/filepath"
)
type Forgejo struct {
@ -108,6 +110,16 @@ func (f *Forgejo) handle(w http.ResponseWriter, r *http.Request) {
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)
@ -119,6 +131,15 @@ func (f *Forgejo) handle(w http.ResponseWriter, r *http.Request) {
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)