31 Commits
v2.5 ... v3.1

Author SHA1 Message Date
6543
8207586a48 just fix bcaceda711 2022-07-15 21:39:42 +02:00
6543
bcaceda711 dont cache if ContentLength greater fileCacheSizeLimit (#108)
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/108
Reviewed-by: Otto <otto@codeberg.org>
2022-07-15 21:21:26 +02:00
6543
5411c96ef3 Tell fasthttp to not set "Content-Length: 0" on non cached content (#107)
fix #97

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/107
2022-07-15 21:06:05 +02:00
Jeremy
baf4e7e326 Make the 404 page more readable and natural (#104)
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/104
Reviewed-by: 6543 <6543@noreply.codeberg.org>
Co-authored-by: Jeremy <jtbx@noreply.codeberg.org>
Co-committed-by: Jeremy <jtbx@noreply.codeberg.org>
2022-07-15 17:18:25 +02:00
Gusted
fd24b4a2bc Pass logger to fasthttp (#98)
- Use a logger with `FASTHTTP` prefix as fasthttp's logger so it's easy to see what fasthttp is logging in console/journal.

Co-authored-by: Gusted <williamzijl7@hotmail.com>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/98
Reviewed-by: 6543 <6543@noreply.codeberg.org>
Co-authored-by: Gusted <gusted@noreply.codeberg.org>
Co-committed-by: Gusted <gusted@noreply.codeberg.org>
2022-07-12 15:32:48 +02:00
Gary Wang
9076bc3f75 Support access branch that contains slash character (#102)
So we can access branch that contain slash like `branch/name` with `username.codeberg.page/repo/@branch~name/`.

Branch name cannot contain `~` character but it can be in a HTTP URL, so replace the `~` from URL to `/` could be a valid solution to me.

Resolve #101

Co-authored-by: Gary Wang <wzc782970009@gmail.com>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/102
Reviewed-by: 6543 <6543@noreply.codeberg.org>
Co-authored-by: Gary Wang <blumia@noreply.codeberg.org>
Co-committed-by: Gary Wang <blumia@noreply.codeberg.org>
2022-07-08 13:39:24 +02:00
Gusted
48a49f69a7 Increase concurrent connections to default value (#99)
Use the default value of `256 * 1024` for the concurrency limit, this will mean that the server will be able to handle more connections.

Co-authored-by: Gusted <williamzijl7@hotmail.com>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/99
Reviewed-by: 6543 <6543@noreply.codeberg.org>
Co-authored-by: Gusted <gusted@noreply.codeberg.org>
Co-committed-by: Gusted <gusted@noreply.codeberg.org>
2022-07-03 13:20:02 +02:00
6543
6dedd55eb3 Release via CI (#94)
* release via CI
* general CI improvements

close #76, close #92

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/94
2022-06-14 20:35:11 +02:00
6543
4c6164ef05 Propagate ETag from gitea (#93)
close #15

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/93
2022-06-14 18:23:34 +02:00
6543
cc32bab31f Enhance joinURL and return error on gitea client on start instead while running (#88)
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/88
2022-06-13 20:07:32 +02:00
6543
913f762eb0 Add integration test for custom domain (#90)
and some nits

---
close #89

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/90
2022-06-13 14:43:49 +02:00
crystal
38fb28f84f implement custom 404 pages (#81)
solves #56.

- The expected filename is `404.html`, like GitHub Pages
- Each repo/branch can have one `404.html` file at it's root
- If a repo does not have a `pages` branch, the 404.html file from the `pages` repository is used
- You get status code 404 (unless you request /404.html which returns 200)
- The error page is cached

---
close #56

Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/81
Reviewed-by: 6543 <6543@noreply.codeberg.org>
Co-authored-by: crystal <crystal@noreply.codeberg.org>
Co-committed-by: crystal <crystal@noreply.codeberg.org>
2022-06-12 03:50:00 +02:00
6543
35b35c5d67 Add integration tests (#86)
close #82
close #32

make sure we dont get regressions again ... as we currently have in **main**

followups:
 - create a DNS subdomayn specific to redirect to mock url ...

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/86
Reviewed-by: crapStone <crapstone@noreply.codeberg.org>
2022-06-11 23:17:43 +02:00
6543
02bd942b04 Move gitea api calls in own "client" package (#78)
continue #75
close #16
- fix regression (from #34) _thanks to @crystal_
- create own gitea client package
- more logging
- add mock impl of CertDB

Co-authored-by: 6543 <6543@obermui.de>
Co-authored-by: crystal <crystal@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/78
Reviewed-by: crapStone <crapstone@noreply.codeberg.org>
2022-06-11 23:02:06 +02:00
6543
659932521c Add info how to test & debug the server (#85)
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/85
2022-06-10 20:17:07 +02:00
6543
bb8eb32ee2 make debug messages unique 2022-06-10 15:29:47 +02:00
6543
f2ba7eac64 set golang to 1.18 (#84)
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/84
2022-06-10 15:27:17 +02:00
6543
57076a47d3 Update 'Justfile' 2022-05-30 23:55:37 +02:00
6543
6f12f2a8e4 fix bug 2022-05-15 22:36:12 +02:00
Moritz Marquardt
b2ca888050 Change MaxConnsPerIP to 0 to fix too many connections from HAProxy (#77)
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/77
Reviewed-by: 6543 <6543@noreply.codeberg.org>
Co-authored-by: Moritz Marquardt <momar@noreply.codeberg.org>
Co-committed-by: Moritz Marquardt <momar@noreply.codeberg.org>
2022-05-14 22:29:54 +02:00
6543
2dbc66d052 let golangci-lint have 5m to check 2022-05-10 18:14:28 +02:00
6543
1724d9fb2e add "lint" to Justfile 2022-05-10 18:13:14 +02:00
6543
4267d54a63 refactor (2) (#34)
move forward with refactoring:
 - initial implementation of a smal "gitea client for fasthttp"
 - move constant into const.go

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/34
Reviewed-by: Otto Richter <otto@codeberg.org>
2022-04-20 23:42:01 +02:00
Otto Richter
a2c5376d9a Fix CORS / add Access-Control-Allow-Origin * to all methods (#69)
The header is not only necessary on the OPTIONS request, but on any method, so I removed the condition.

Serving any workadventure map was broken BTW. We should have tested this :-(

Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/69
Reviewed-by: Andreas Shimokawa <ashimokawa@noreply.codeberg.org>
Co-authored-by: Otto Richter <otto@codeberg.org>
Co-committed-by: Otto Richter <otto@codeberg.org>
2022-04-10 18:11:00 +02:00
6543
1e4dfe2ae8 Fix tests to let CI pass (#66)
Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/66
Reviewed-by: Otto Richter <otto@codeberg.org>
2022-03-30 21:31:09 +02:00
6543
f5d0dc7447 Add pipeline (#65)
close #54

Co-authored-by: 6543 <6543@obermui.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/65
Reviewed-by: Andreas Shimokawa <ashimokawa@noreply.codeberg.org>
2022-03-27 21:54:06 +02:00
Moritz Marquardt
a5504acb0e Fix cert removal command (#50)
The command was using parts from the old os.Args approach and parts from the cli package, and together they didn't work at all. This fixes that and makes the command `pages-server certs remove [domain...]`.

Co-authored-by: Moritz Marquardt <git@momar.de>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/50
Co-authored-by: Moritz Marquardt <momar@noreply.codeberg.org>
Co-committed-by: Moritz Marquardt <momar@noreply.codeberg.org>
2022-03-20 23:18:00 +01:00
Moritz Marquardt
f5e613bfdb Merge pull request 'Fix certs only being renewed 7 or 30 days *after* they expire instead of before' (#61) from hotfix/expiration into main
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/61
2022-02-28 21:55:51 +01:00
Moritz Marquardt
cf9e6d9dc6 Fix certs only being renewed 7 or 30 days *after* they expire instead of before
Seems like plus, minus, greater than and less than are the most complex to understand mathematical concepts...
2022-02-28 21:50:13 +01:00
Otto Richter
ac5b19123d Update README (#57)
I hope this makes it more inviting to collaborate with us on this project. I'd like to promote the software a little more.

Co-authored-by: fnetx <git@fralix.ovh>
Reviewed-on: https://codeberg.org/Codeberg/pages-server/pulls/57
Co-authored-by: Otto Richter <fnetx@noreply.codeberg.org>
Co-committed-by: Otto Richter <fnetx@noreply.codeberg.org>
2022-02-19 18:10:40 +01:00
fnetx
4404287958 Update 404 Not found page 2022-02-11 01:31:11 +01:00
31 changed files with 1065 additions and 294 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ key-database.pogreb/
acme-account.json acme-account.json
build/ build/
vendor/ vendor/
pages

69
.woodpecker.yml Normal file
View File

@@ -0,0 +1,69 @@
branches: main
pipeline:
# use vendor to cache dependencies
vendor:
image: golang:1.18
commands:
- go mod vendor
lint:
image: golangci/golangci-lint:latest
group: compliant
pull: true
commands:
- go version
- go install mvdan.cc/gofumpt@latest
- "[ $(gofumpt -extra -l . | wc -l) != 0 ] && { echo 'code not formated'; exit 1; }"
- golangci-lint run --timeout 5m --build-tags integration
build:
group: compliant
image: a6543/golang_just
commands:
- go version
- just build
when:
event: [ "pull_request", "push" ]
build-tag:
group: compliant
image: a6543/golang_just
commands:
- go version
- just build-tag ${CI_COMMIT_TAG##v}
when:
event: [ "tag" ]
test:
image: a6543/golang_just
group: test
commands:
- just test
integration-tests:
image: a6543/golang_just
group: test
commands:
- just integration
environment:
- ACME_API=https://acme.mock.directory
- PAGES_DOMAIN=localhost.mock.directory
- RAW_DOMAIN=raw.localhost.mock.directory
- PORT=4430
release:
image: plugins/gitea-release
settings:
base_url: https://codeberg.org
file_exists: overwrite
files: build/codeberg-pages-server
api_key:
from_secret: bot_token
environment:
- DRONE_REPO_OWNER=${CI_REPO_OWNER}
- DRONE_REPO_NAME=${CI_REPO_NAME}
- DRONE_BUILD_EVENT=${CI_BUILD_EVENT}
- DRONE_COMMIT_REF=${CI_COMMIT_REF}
when:
event: [ "tag" ]

View File

@@ -10,3 +10,39 @@ dev:
build: build:
CGO_ENABLED=0 go build -ldflags '-s -w' -v -o build/codeberg-pages-server ./ CGO_ENABLED=0 go build -ldflags '-s -w' -v -o build/codeberg-pages-server ./
build-tag VERSION:
CGO_ENABLED=0 go build -ldflags '-s -w -X "codeberg.org/codeberg/pages/server/version.Version={{VERSION}}"' -v -o build/codeberg-pages-server ./
lint: tool-golangci tool-gofumpt
[ $(gofumpt -extra -l . | wc -l) != 0 ] && { echo 'code not formated'; exit 1; }; \
golangci-lint run --timeout 5m --build-tags integration
fmt: tool-gofumpt
gofumpt -w --extra .
clean:
go clean ./...
rm -rf build/
tool-golangci:
@hash golangci-lint> /dev/null 2>&1; if [ $? -ne 0 ]; then \
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \
fi
tool-gofumpt:
@hash gofumpt> /dev/null 2>&1; if [ $? -ne 0 ]; then \
go install mvdan.cc/gofumpt@latest; \
fi
test:
go test -race codeberg.org/codeberg/pages/server/...
test-run TEST:
go test -race -run "^{{TEST}}$" codeberg.org/codeberg/pages/server/...
integration:
go test -race -tags integration codeberg.org/codeberg/pages/integration/...
integration-run TEST:
go test -race -tags integration -run "^{{TEST}}$" codeberg.org/codeberg/pages/integration/...

112
README.md
View File

@@ -1,4 +1,59 @@
## Environment # Codeberg Pages
Gitea lacks the ability to host static pages from Git.
The Codeberg Pages Server addresses this lack by implementing a standalone service
that connects to Gitea via API.
It is suitable to be deployed by other Gitea instances, too, to offer static pages hosting to their users.
**End user documentation** can mainly be found at the [Wiki](https://codeberg.org/Codeberg/pages-server/wiki/Overview)
and the [Codeberg Documentation](https://docs.codeberg.org/codeberg-pages/).
## Quickstart
This is the new Codeberg Pages server, a solution for serving static pages from Gitea repositories.
Mapping custom domains is not static anymore, but can be done with DNS:
1) add a `.domains` text file to your repository, containing the allowed domains, separated by new lines. The
first line will be the canonical domain/URL; all other occurrences will be redirected to it.
2) add a CNAME entry to your domain, pointing to `[[{branch}.]{repo}.]{owner}.codeberg.page` (repo defaults to
"pages", "branch" defaults to the default branch if "repo" is "pages", or to "pages" if "repo" is something else.
If the branch name contains slash characters, you need to replace "/" in the branch name to "~"):
`www.example.org. IN CNAME main.pages.example.codeberg.page.`
3) if a CNAME is set for "www.example.org", you can redirect there from the naked domain by adding an ALIAS record
for "example.org" (if your provider allows ALIAS or similar records, otherwise use A/AAAA), together with a TXT
record that points to your repo (just like the CNAME record):
`example.org IN ALIAS codeberg.page.`
`example.org IN TXT main.pages.example.codeberg.page.`
Certificates are generated, updated and cleaned up automatically via Let's Encrypt through a TLS challenge.
## Deployment
**Warning: Some Caveats Apply**
> Currently, the deployment requires you to have some knowledge of system administration as well as understanding and building code,
> so you can eventually edit non-configurable and codeberg-specific settings.
> In the future, we'll try to reduce these and make hosting Codeberg Pages as easy as setting up Gitea.
> If you consider using Pages in practice, please consider contacting us first,
> we'll then try to share some basic steps and document the current usage for admins
> (might be changing in the current state).
Deploying the software itself is very easy. You can grab a current release binary or build yourself,
configure the environment as described below, and you are done.
The hard part is about adding **custom domain support** if you intend to use it.
SSL certificates (request + renewal) is automatically handled by the Pages Server,
but if you want to run it on a shared IP address (and not a standalone),
you'll need to configure your reverse proxy not to terminate the TLS connections,
but forward the requests on the IP level to the Pages Server.
You can check out a proof of concept in the `haproxy-sni` folder,
and especially have a look at [this section of the haproxy.cfg](https://codeberg.org/Codeberg/pages-server/src/branch/main/haproxy-sni/haproxy.cfg#L38).
### Environment
- `HOST` & `PORT` (default: `[::]` & `443`): listen address. - `HOST` & `PORT` (default: `[::]` & `443`): listen address.
- `PAGES_DOMAIN` (default: `codeberg.page`): main domain for pages. - `PAGES_DOMAIN` (default: `codeberg.page`): main domain for pages.
@@ -17,23 +72,38 @@
See https://go-acme.github.io/lego/dns/ for available values & additional environment variables. See https://go-acme.github.io/lego/dns/ for available values & additional environment variables.
- `DEBUG` (default: false): Set this to true to enable debug logging. - `DEBUG` (default: false): Set this to true to enable debug logging.
```
// Package main is the new Codeberg Pages server, a solution for serving static pages from Gitea repositories. ## Contributing to the development
//
// Mapping custom domains is not static anymore, but can be done with DNS: The Codeberg team is very open to your contribution.
// Since we are working nicely in a team, it might be hard at times to get started
// 1) add a ".domains" text file to your repository, containing the allowed domains, separated by new lines. The (still check out the issues, we always aim to have some things to get you started).
// first line will be the canonical domain/URL; all other occurrences will be redirected to it.
// If you have any questions, want to work on a feature or could imagine collaborating with us for some time,
// 2) add a CNAME entry to your domain, pointing to "[[{branch}.]{repo}.]{owner}.codeberg.page" (repo defaults to feel free to ping us in an issue or in a general Matrix chatgroup.
// "pages", "branch" defaults to the default branch if "repo" is "pages", or to "pages" if "repo" is something else):
// www.example.org. IN CNAME main.pages.example.codeberg.page. You can also contact the maintainers of this project:
//
// 3) if a CNAME is set for "www.example.org", you can redirect there from the naked domain by adding an ALIAS record - [momar](https://codeberg.org/momar) [(Matrix)](https://matrix.to/#/@moritz:wuks.space)
// for "example.org" (if your provider allows ALIAS or similar records, otherwise use A/AAAA), together with a TXT - [6543](https://codeberg.org/6543) [(Matrix)](https://matrix.to/#/@marddl:obermui.de)
// record that points to your repo (just like the CNAME record):
// example.org IN ALIAS codeberg.page. ### First steps
// example.org IN TXT main.pages.example.codeberg.page.
// The code of this repository is split in several modules.
// Certificates are generated, updated and cleaned up automatically via Let's Encrypt through a TLS challenge. While heavy refactoring work is currently undergo, you can easily understand the basic structure:
``` The `cmd` folder holds the data necessary for interacting with the service via the cli.
If you are considering to deploy the service yourself, make sure to check it out.
The heart of the software lives in the `server` folder and is split in several modules.
After scanning the code, you should quickly be able to understand their function and start hacking on them.
Again: Feel free to get in touch with us for any questions that might arise.
Thank you very much.
### Test Server
run `just dev`
now this pages should work:
- https://magiclike.localhost.mock.directory:4430/
- https://momar.localhost.mock.directory:4430/ci-testing/
- https://momar.localhost.mock.directory:4430/pag/@master/

View File

@@ -2,43 +2,71 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"github.com/akrylysov/pogreb"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/server/database" "codeberg.org/codeberg/pages/server/database"
) )
var Certs = &cli.Command{ var Certs = &cli.Command{
Name: "certs", Name: "certs",
Usage: "manage certs manually", Usage: "manage certs manually",
Action: certs, Subcommands: []*cli.Command{
{
Name: "list",
Usage: "list all certificates in the database",
Action: listCerts,
},
{
Name: "remove",
Usage: "remove a certificate from the database",
Action: removeCert,
},
},
} }
func certs(ctx *cli.Context) error { func listCerts(ctx *cli.Context) error {
if ctx.Args().Len() >= 1 && ctx.Args().First() == "--remove-certificate" { // TODO: make "key-database.pogreb" set via flag
if ctx.Args().Len() == 1 { keyDatabase, err := database.New("key-database.pogreb")
println("--remove-certificate requires at least one domain as an argument") if err != nil {
os.Exit(1) return fmt.Errorf("could not create database: %v", err)
} }
domains := ctx.Args().Slice()[2:] items := keyDatabase.Items()
for domain, _, err := items.Next(); err != pogreb.ErrIterationDone; domain, _, err = items.Next() {
// TODO: make "key-database.pogreb" set via flag
keyDatabase, err := database.New("key-database.pogreb")
if err != nil { if err != nil {
return fmt.Errorf("could not create database: %v", err) return err
} }
if domain[0] == '.' {
for _, domain := range domains { fmt.Printf("*")
if err := keyDatabase.Delete([]byte(domain)); err != nil {
panic(err)
}
} }
if err := keyDatabase.Close(); err != nil { fmt.Printf("%s\n", domain)
panic(err) }
} return nil
os.Exit(0) }
func removeCert(ctx *cli.Context) error {
if ctx.Args().Len() < 1 {
return fmt.Errorf("'certs remove' requires at least one domain as an argument")
}
domains := ctx.Args().Slice()
// TODO: make "key-database.pogreb" set via flag
keyDatabase, err := database.New("key-database.pogreb")
if err != nil {
return fmt.Errorf("could not create database: %v", err)
}
for _, domain := range domains {
fmt.Printf("Removing domain %s from the database...\n", domain)
if err := keyDatabase.Delete(domain); err != nil {
return err
}
}
if err := keyDatabase.Close(); err != nil {
return err
} }
return nil return nil
} }

View File

@@ -10,7 +10,7 @@ var ServeFlags = []cli.Flag{
// TODO: Usage // TODO: Usage
EnvVars: []string{"DEBUG"}, EnvVars: []string{"DEBUG"},
}, },
// MainDomainSuffix specifies the main domain (starting with a dot) for which subdomains shall be served as static // MainDomainSuffix specifies the main domain (starting with a dot) for which subdomains shall be served as static
// pages, or used for comparison in CNAME lookups. Static pages can be accessed through // pages, or used for comparison in CNAME lookups. Static pages can be accessed through
// https://{owner}.{MainDomain}[/{repo}], with repo defaulting to "pages". // https://{owner}.{MainDomain}[/{repo}], with repo defaulting to "pages".

View File

@@ -18,6 +18,7 @@ import (
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/certificates" "codeberg.org/codeberg/pages/server/certificates"
"codeberg.org/codeberg/pages/server/database" "codeberg.org/codeberg/pages/server/database"
"codeberg.org/codeberg/pages/server/gitea"
) )
// AllowedCorsDomains lists the domains for which Cross-Origin Resource Sharing is allowed. // AllowedCorsDomains lists the domains for which Cross-Origin Resource Sharing is allowed.
@@ -72,18 +73,24 @@ func Serve(ctx *cli.Context) error {
keyCache := cache.NewKeyValueCache() keyCache := cache.NewKeyValueCache()
challengeCache := cache.NewKeyValueCache() challengeCache := cache.NewKeyValueCache()
// canonicalDomainCache stores canonical domains // canonicalDomainCache stores canonical domains
var canonicalDomainCache = cache.NewKeyValueCache() canonicalDomainCache := cache.NewKeyValueCache()
// dnsLookupCache stores DNS lookups for custom domains // dnsLookupCache stores DNS lookups for custom domains
var dnsLookupCache = cache.NewKeyValueCache() dnsLookupCache := cache.NewKeyValueCache()
// branchTimestampCache stores branch timestamps for faster cache checking // branchTimestampCache stores branch timestamps for faster cache checking
var branchTimestampCache = cache.NewKeyValueCache() branchTimestampCache := cache.NewKeyValueCache()
// fileResponseCache stores responses from the Gitea server // fileResponseCache stores responses from the Gitea server
// TODO: make this an MRU cache with a size limit // TODO: make this an MRU cache with a size limit
var fileResponseCache = cache.NewKeyValueCache() fileResponseCache := cache.NewKeyValueCache()
giteaClient, err := gitea.NewClient(giteaRoot, giteaAPIToken)
if err != nil {
return fmt.Errorf("could not create new gitea client: %v", err)
}
// Create handler based on settings // Create handler based on settings
handler := server.Handler(mainDomainSuffix, []byte(rawDomain), handler := server.Handler(mainDomainSuffix, []byte(rawDomain),
giteaRoot, rawInfoPage, giteaAPIToken, giteaClient,
giteaRoot, rawInfoPage,
BlacklistedPaths, allowedCorsDomains, BlacklistedPaths, allowedCorsDomains,
dnsLookupCache, canonicalDomainCache, branchTimestampCache, fileResponseCache) dnsLookupCache, canonicalDomainCache, branchTimestampCache, fileResponseCache)
@@ -105,7 +112,8 @@ func Serve(ctx *cli.Context) error {
defer certDB.Close() //nolint:errcheck // database has no close ... sync behave like it defer certDB.Close() //nolint:errcheck // database has no close ... sync behave like it
listener = tls.NewListener(listener, certificates.TLSConfig(mainDomainSuffix, listener = tls.NewListener(listener, certificates.TLSConfig(mainDomainSuffix,
giteaRoot, giteaAPIToken, dnsProvider, giteaClient,
dnsProvider,
acmeUseRateLimits, acmeUseRateLimits,
keyCache, challengeCache, dnsLookupCache, canonicalDomainCache, keyCache, challengeCache, dnsLookupCache, canonicalDomainCache,
certDB)) certDB))
@@ -126,6 +134,7 @@ func Serve(ctx *cli.Context) error {
if enableHTTPServer { if enableHTTPServer {
go func() { go func() {
log.Info().Timestamp().Msg("Start listening on :80")
err := httpServer.ListenAndServe("[::]:80") err := httpServer.ListenAndServe("[::]:80")
if err != nil { if err != nil {
log.Panic().Err(err).Msg("Couldn't start HTTP fastServer") log.Panic().Err(err).Msg("Couldn't start HTTP fastServer")
@@ -134,6 +143,7 @@ func Serve(ctx *cli.Context) error {
} }
// Start the web fastServer // Start the web fastServer
log.Info().Timestamp().Msgf("Start listening on %s", listener.Addr())
err = fastServer.Serve(listener) err = fastServer.Serve(listener)
if err != nil { if err != nil {
log.Panic().Err(err).Msg("Couldn't start fastServer") log.Panic().Err(err).Msg("Couldn't start fastServer")

114
go.mod
View File

@@ -1,6 +1,6 @@
module codeberg.org/codeberg/pages module codeberg.org/codeberg/pages
go 1.16 go 1.18
require ( require (
github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a
@@ -13,3 +13,115 @@ require (
github.com/valyala/fasthttp v1.31.0 github.com/valyala/fasthttp v1.31.0
github.com/valyala/fastjson v1.6.3 github.com/valyala/fastjson v1.6.3
) )
require (
cloud.google.com/go v0.54.0 // indirect
github.com/Azure/azure-sdk-for-go v32.4.0+incompatible // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.19 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.1.1 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183 // indirect
github.com/andybalholm/brotli v1.0.2 // indirect
github.com/aws/aws-sdk-go v1.39.0 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cenkalti/backoff/v4 v4.1.1 // indirect
github.com/cloudflare/cloudflare-go v0.20.0 // indirect
github.com/cpu/goacmedns v0.1.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deepmap/oapi-codegen v1.6.1 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dnsimple/dnsimple-go v0.70.1 // indirect
github.com/exoscale/egoscale v0.67.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 // indirect
github.com/gofrs/uuid v3.2.0+incompatible // indirect
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.1.1 // indirect
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
github.com/gophercloud/gophercloud v0.16.0 // indirect
github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae // indirect
github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.0 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
github.com/jarcoal/httpmock v1.0.6 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/json-iterator/go v1.1.7 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/klauspost/compress v1.13.4 // indirect
github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/linode/linodego v0.31.1 // indirect
github.com/liquidweb/go-lwApi v0.0.5 // indirect
github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
github.com/liquidweb/liquidweb-go v1.6.3 // indirect
github.com/mattn/go-isatty v0.0.12 // indirect
github.com/miekg/dns v1.1.43 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.4.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/auroradns v1.0.1 // indirect
github.com/nrdcg/desec v0.6.0 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.2.0 // indirect
github.com/nrdcg/goinwx v0.8.1 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/nrdcg/porkbun v0.1.1 // indirect
github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
github.com/ovh/go-ovh v1.1.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pquerna/otp v1.3.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/sacloud/libsacloud v1.36.2 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.0.3 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/stretchr/objx v0.3.0 // indirect
github.com/transip/gotransip/v6 v6.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 // indirect
github.com/vultr/govultr/v2 v2.7.1 // indirect
go.opencensus.io v0.22.3 // indirect
go.uber.org/ratelimit v0.0.0-20180316092928-c15da0234277 // indirect
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e // indirect
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 // indirect
google.golang.org/api v0.20.0 // indirect
google.golang.org/appengine v1.6.5 // indirect
google.golang.org/genproto v0.0.0-20200305110556-506484158171 // indirect
google.golang.org/grpc v1.27.1 // indirect
google.golang.org/protobuf v1.26.0 // indirect
gopkg.in/ini.v1 v1.62.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.6.2 // indirect
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)

3
go.sum
View File

@@ -266,6 +266,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -486,7 +488,6 @@ github.com/transip/gotransip/v6 v6.6.1 h1:nsCU1ErZS5G0FeOpgGXc4FsWvBff9GPswSMggs
github.com/transip/gotransip/v6 v6.6.1/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g= github.com/transip/gotransip/v6 v6.6.1/go.mod h1:pQZ36hWWRahCUXkFWlx9Hs711gLd8J4qdgLdRzmtY+g=
github.com/uber-go/atomic v1.3.2 h1:Azu9lPBWRNKzYXSIwRfgRuDuS0YKsK4NFhiQv98gkxo= github.com/uber-go/atomic v1.3.2 h1:Azu9lPBWRNKzYXSIwRfgRuDuS0YKsK4NFhiQv98gkxo=
github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g= github.com/uber-go/atomic v1.3.2/go.mod h1:/Ct5t2lcmbJ4OSe/waGBoaVvVqtO0bmtfVNex1PFV8g=
github.com/urfave/cli v1.22.5 h1:lNq9sAHXK2qfdI8W+GRItjCEkI+2oR4d+MEHy1CKXoU=
github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=

View File

@@ -21,12 +21,13 @@
</style> </style>
</head> </head>
<body> <body>
<i class="fa fa-bug text-primary" style="font-size: 96px;"></i> <i class="fa fa-search text-primary" style="font-size: 96px;"></i>
<h1 class="mb-0 text-primary"> <h1 class="mb-0 text-primary">
You found a bug! Page not found!
</h1> </h1>
<h5 class="text-center" style="max-width: 25em;"> <h5 class="text-center" style="max-width: 25em;">
Sorry, this page doesn't exist or is inaccessible for other reasons (%status) Sorry, but this page couldn't be found or is inaccessible (%status).<br/>
We hope this isn't a problem on our end ;) - Make sure to check the <a href="https://docs.codeberg.org/codeberg-pages/troubleshooting/" target="_blank">troubleshooting section in the Docs</a>!
</h5> </h5>
<small class="text-muted"> <small class="text-muted">
<img src="https://design.codeberg.org/logo-kit/icon.svg" class="align-top"> <img src="https://design.codeberg.org/logo-kit/icon.svg" class="align-top">

108
integration/get_test.go Normal file
View File

@@ -0,0 +1,108 @@
//go:build integration
// +build integration
package integration
import (
"bytes"
"crypto/tls"
"io"
"net/http"
"net/http/cookiejar"
"testing"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
)
func TestGetRedirect(t *testing.T) {
log.Printf("=== TestGetRedirect ===\n")
// test custom domain redirect
resp, err := getTestHTTPSClient().Get("https://calciumdibromid.localhost.mock.directory:4430")
assert.NoError(t, err)
if !assert.EqualValues(t, http.StatusTemporaryRedirect, resp.StatusCode) {
t.FailNow()
}
assert.EqualValues(t, "https://www.cabr2.de/", resp.Header.Get("Location"))
assert.EqualValues(t, 0, getSize(resp.Body))
}
func TestGetContent(t *testing.T) {
log.Printf("=== TestGetContent ===\n")
// test get image
resp, err := getTestHTTPSClient().Get("https://magiclike.localhost.mock.directory:4430/images/827679288a.jpg")
assert.NoError(t, err)
if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) {
t.FailNow()
}
assert.EqualValues(t, "image/jpeg", resp.Header.Get("Content-Type"))
assert.EqualValues(t, "124635", resp.Header.Get("Content-Length"))
assert.EqualValues(t, 124635, getSize(resp.Body))
assert.Len(t, resp.Header.Get("ETag"), 42)
// specify branch
resp, err = getTestHTTPSClient().Get("https://momar.localhost.mock.directory:4430/pag/@master/")
assert.NoError(t, err)
if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) {
t.FailNow()
}
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
assert.True(t, getSize(resp.Body) > 1000)
assert.Len(t, resp.Header.Get("ETag"), 42)
// access branch name contains '/'
resp, err = getTestHTTPSClient().Get("https://blumia.localhost.mock.directory:4430/pages-server-integration-tests/@docs~main/")
assert.NoError(t, err)
if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) {
t.FailNow()
}
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
assert.True(t, getSize(resp.Body) > 100)
assert.Len(t, resp.Header.Get("ETag"), 42)
// TODO: test get of non cachable content (content size > fileCacheSizeLimit)
}
func TestCustomDomain(t *testing.T) {
log.Printf("=== TestCustomDomain ===\n")
resp, err := getTestHTTPSClient().Get("https://mock-pages.codeberg-test.org:4430/README.md")
assert.NoError(t, err)
if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) {
t.FailNow()
}
assert.EqualValues(t, "text/markdown; charset=utf-8", resp.Header.Get("Content-Type"))
assert.EqualValues(t, "106", resp.Header.Get("Content-Length"))
assert.EqualValues(t, 106, getSize(resp.Body))
}
func TestGetNotFound(t *testing.T) {
log.Printf("=== TestGetNotFound ===\n")
// test custom not found pages
resp, err := getTestHTTPSClient().Get("https://crystal.localhost.mock.directory:4430/pages-404-demo/blah")
assert.NoError(t, err)
if !assert.EqualValues(t, http.StatusNotFound, resp.StatusCode) {
t.FailNow()
}
assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
assert.EqualValues(t, "37", resp.Header.Get("Content-Length"))
assert.EqualValues(t, 37, getSize(resp.Body))
}
func getTestHTTPSClient() *http.Client {
cookieJar, _ := cookiejar.New(nil)
return &http.Client{
Jar: cookieJar,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
}
func getSize(stream io.Reader) int {
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(stream)
return buf.Len()
}

62
integration/main_test.go Normal file
View File

@@ -0,0 +1,62 @@
//go:build integration
// +build integration
package integration
import (
"context"
"os"
"testing"
"time"
"codeberg.org/codeberg/pages/cmd"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
)
func TestMain(m *testing.M) {
log.Printf("=== TestMain: START Server ===\n")
serverCtx, serverCancel := context.WithCancel(context.Background())
if err := startServer(serverCtx); err != nil {
log.Fatal().Msgf("could not start server: %v", err)
}
defer func() {
serverCancel()
log.Printf("=== TestMain: Server STOPED ===\n")
}()
time.Sleep(10 * time.Second)
os.Exit(m.Run())
}
func startServer(ctx context.Context) error {
args := []string{
"--verbose",
"--acme-accept-terms", "true",
}
setEnvIfNotSet("ACME_API", "https://acme.mock.directory")
setEnvIfNotSet("PAGES_DOMAIN", "localhost.mock.directory")
setEnvIfNotSet("RAW_DOMAIN", "raw.localhost.mock.directory")
setEnvIfNotSet("PORT", "4430")
app := cli.NewApp()
app.Name = "pages-server"
app.Action = cmd.Serve
app.Flags = cmd.ServeFlags
go func() {
if err := app.RunContext(ctx, args); err != nil {
log.Fatal().Msgf("run server error: %v", err)
}
}()
return nil
}
func setEnvIfNotSet(key, value string) {
if _, set := os.LookupEnv(key); !set {
os.Setenv(key, value)
}
}

View File

@@ -4,15 +4,14 @@ import (
"fmt" "fmt"
"os" "os"
_ "github.com/joho/godotenv/autoload"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/cmd" "codeberg.org/codeberg/pages/cmd"
) )
var ( // can be changed with -X on compile
// can be changed with -X on compile var version = "dev"
version = "dev"
)
func main() { func main() {
app := cli.NewApp() app := cli.NewApp()

View File

@@ -19,9 +19,11 @@ var _ registration.User = &AcmeAccount{}
func (u *AcmeAccount) GetEmail() string { func (u *AcmeAccount) GetEmail() string {
return u.Email return u.Email
} }
func (u AcmeAccount) GetRegistration() *registration.Resource { func (u AcmeAccount) GetRegistration() *registration.Resource {
return u.Registration return u.Registration
} }
func (u *AcmeAccount) GetPrivateKey() crypto.PrivateKey { func (u *AcmeAccount) GetPrivateKey() crypto.PrivateKey {
return u.Key return u.Key
} }

View File

@@ -32,15 +32,18 @@ import (
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/database" "codeberg.org/codeberg/pages/server/database"
dnsutils "codeberg.org/codeberg/pages/server/dns" dnsutils "codeberg.org/codeberg/pages/server/dns"
"codeberg.org/codeberg/pages/server/gitea"
"codeberg.org/codeberg/pages/server/upstream" "codeberg.org/codeberg/pages/server/upstream"
) )
// TLSConfig returns the configuration for generating, serving and cleaning up Let's Encrypt certificates. // TLSConfig returns the configuration for generating, serving and cleaning up Let's Encrypt certificates.
func TLSConfig(mainDomainSuffix []byte, func TLSConfig(mainDomainSuffix []byte,
giteaRoot, giteaAPIToken, dnsProvider string, giteaClient *gitea.Client,
dnsProvider string,
acmeUseRateLimits bool, acmeUseRateLimits bool,
keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.SetGetKey, keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.SetGetKey,
certDB database.CertDB) *tls.Config { certDB database.CertDB,
) *tls.Config {
return &tls.Config{ return &tls.Config{
// check DNS name & get certificate from Let's Encrypt // check DNS name & get certificate from Let's Encrypt
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
@@ -80,7 +83,7 @@ func TLSConfig(mainDomainSuffix []byte,
sni = string(sniBytes) sni = string(sniBytes)
} else { } else {
_, _ = targetRepo, targetBranch _, _ = targetRepo, targetBranch
_, valid := upstream.CheckCanonicalDomain(targetOwner, targetRepo, targetBranch, sni, string(mainDomainSuffix), giteaRoot, giteaAPIToken, canonicalDomainCache) _, valid := upstream.CheckCanonicalDomain(giteaClient, targetOwner, targetRepo, targetBranch, sni, string(mainDomainSuffix), canonicalDomainCache)
if !valid { if !valid {
sniBytes = mainDomainSuffix sniBytes = mainDomainSuffix
sni = string(sniBytes) sni = string(sniBytes)
@@ -146,8 +149,10 @@ func checkUserLimit(user string) error {
return nil return nil
} }
var acmeClient, mainDomainAcmeClient *lego.Client var (
var acmeClientCertificateLimitPerUser = map[string]*equalizer.TokenBucket{} acmeClient, mainDomainAcmeClient *lego.Client
acmeClientCertificateLimitPerUser = map[string]*equalizer.TokenBucket{}
)
// rate limit is 300 / 3 hours, we want 200 / 2 hours but to refill more often, so that's 25 new domains every 15 minutes // rate limit is 300 / 3 hours, we want 200 / 2 hours but to refill more often, so that's 25 new domains every 15 minutes
// TODO: when this is used a lot, we probably have to think of a somewhat better solution? // TODO: when this is used a lot, we probably have to think of a somewhat better solution?
@@ -166,6 +171,7 @@ var _ challenge.Provider = AcmeTLSChallengeProvider{}
func (a AcmeTLSChallengeProvider) Present(domain, _, keyAuth string) error { func (a AcmeTLSChallengeProvider) Present(domain, _, keyAuth string) error {
return a.challengeCache.Set(domain, keyAuth, 1*time.Hour) return a.challengeCache.Set(domain, keyAuth, 1*time.Hour)
} }
func (a AcmeTLSChallengeProvider) CleanUp(domain, _, _ string) error { func (a AcmeTLSChallengeProvider) CleanUp(domain, _, _ string) error {
a.challengeCache.Remove(domain) a.challengeCache.Remove(domain)
return nil return nil
@@ -181,6 +187,7 @@ var _ challenge.Provider = AcmeHTTPChallengeProvider{}
func (a AcmeHTTPChallengeProvider) Present(domain, token, keyAuth string) error { func (a AcmeHTTPChallengeProvider) Present(domain, token, keyAuth string) error {
return a.challengeCache.Set(domain+"/"+token, keyAuth, 1*time.Hour) return a.challengeCache.Set(domain+"/"+token, keyAuth, 1*time.Hour)
} }
func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error { func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error {
a.challengeCache.Remove(domain + "/" + token) a.challengeCache.Remove(domain + "/" + token)
return nil return nil
@@ -188,7 +195,7 @@ func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error {
func retrieveCertFromDB(sni, mainDomainSuffix []byte, dnsProvider string, acmeUseRateLimits bool, certDB database.CertDB) (tls.Certificate, bool) { func retrieveCertFromDB(sni, mainDomainSuffix []byte, dnsProvider string, acmeUseRateLimits bool, certDB database.CertDB) (tls.Certificate, bool) {
// parse certificate from database // parse certificate from database
res, err := certDB.Get(sni) res, err := certDB.Get(string(sni))
if err != nil { if err != nil {
panic(err) // TODO: no panic panic(err) // TODO: no panic
} }
@@ -209,7 +216,7 @@ func retrieveCertFromDB(sni, mainDomainSuffix []byte, dnsProvider string, acmeUs
} }
// renew certificates 7 days before they expire // renew certificates 7 days before they expire
if !tlsCertificate.Leaf.NotAfter.After(time.Now().Add(-7 * 24 * time.Hour)) { if !tlsCertificate.Leaf.NotAfter.After(time.Now().Add(7 * 24 * time.Hour)) {
// TODO: add ValidUntil to custom res struct // TODO: add ValidUntil to custom res struct
if res.CSR != nil && len(res.CSR) > 0 { if res.CSR != nil && len(res.CSR) > 0 {
// CSR stores the time when the renewal shall be tried again // CSR stores the time when the renewal shall be tried again
@@ -388,7 +395,7 @@ func SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID string, acmeAcce
log.Printf("[FAIL] Error during json.Marshal(myAcmeAccount), waiting for manual restart to avoid rate limits: %s", err) log.Printf("[FAIL] Error during json.Marshal(myAcmeAccount), waiting for manual restart to avoid rate limits: %s", err)
select {} select {}
} }
err = ioutil.WriteFile(configFile, acmeAccountJSON, 0600) err = ioutil.WriteFile(configFile, acmeAccountJSON, 0o600)
if err != nil { if err != nil {
log.Printf("[FAIL] Error during ioutil.WriteFile(\"acme-account.json\"), waiting for manual restart to avoid rate limits: %s", err) log.Printf("[FAIL] Error during ioutil.WriteFile(\"acme-account.json\"), waiting for manual restart to avoid rate limits: %s", err)
select {} select {}
@@ -401,7 +408,7 @@ func SetupAcmeConfig(acmeAPI, acmeMail, acmeEabHmac, acmeEabKID string, acmeAcce
func SetupCertificates(mainDomainSuffix []byte, dnsProvider string, acmeConfig *lego.Config, acmeUseRateLimits, enableHTTPServer bool, challengeCache cache.SetGetKey, certDB database.CertDB) error { func SetupCertificates(mainDomainSuffix []byte, dnsProvider string, acmeConfig *lego.Config, acmeUseRateLimits, enableHTTPServer bool, challengeCache cache.SetGetKey, certDB database.CertDB) error {
// getting main cert before ACME account so that we can fail here without hitting rate limits // getting main cert before ACME account so that we can fail here without hitting rate limits
mainCertBytes, err := certDB.Get(mainDomainSuffix) mainCertBytes, err := certDB.Get(string(mainDomainSuffix))
if err != nil { if err != nil {
return fmt.Errorf("cert database is not working") return fmt.Errorf("cert database is not working")
} }
@@ -473,7 +480,7 @@ func MaintainCertDB(ctx context.Context, interval time.Duration, mainDomainSuffi
tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate) tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate)
if err != nil || !tlsCertificates[0].NotAfter.After(now) { if err != nil || !tlsCertificates[0].NotAfter.After(now) {
err := certDB.Delete(key) err := certDB.Delete(string(key))
if err != nil { if err != nil {
log.Printf("[ERROR] Deleting expired certificate for %s failed: %s", string(key), err) log.Printf("[ERROR] Deleting expired certificate for %s failed: %s", string(key), err)
} else { } else {
@@ -486,15 +493,15 @@ func MaintainCertDB(ctx context.Context, interval time.Duration, mainDomainSuffi
log.Printf("[INFO] Removed %d expired certificates from the database", expiredCertCount) log.Printf("[INFO] Removed %d expired certificates from the database", expiredCertCount)
// compact the database // compact the database
result, err := certDB.Compact() msg, err := certDB.Compact()
if err != nil { if err != nil {
log.Printf("[ERROR] Compacting key database failed: %s", err) log.Printf("[ERROR] Compacting key database failed: %s", err)
} else { } else {
log.Printf("[INFO] Compacted key database (%+v)", result) log.Printf("[INFO] Compacted key database (%s)", msg)
} }
// update main cert // update main cert
res, err := certDB.Get(mainDomainSuffix) res, err := certDB.Get(string(mainDomainSuffix))
if err != nil { if err != nil {
log.Err(err).Msgf("could not get cert for domain '%s'", mainDomainSuffix) log.Err(err).Msgf("could not get cert for domain '%s'", mainDomainSuffix)
} else if res == nil { } else if res == nil {
@@ -503,7 +510,7 @@ func MaintainCertDB(ctx context.Context, interval time.Duration, mainDomainSuffi
tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate) tlsCertificates, err := certcrypto.ParsePEMBundle(res.Certificate)
// renew main certificate 30 days before it expires // renew main certificate 30 days before it expires
if !tlsCertificates[0].NotAfter.After(time.Now().Add(-30 * 24 * time.Hour)) { if !tlsCertificates[0].NotAfter.After(time.Now().Add(30 * 24 * time.Hour)) {
go (func() { go (func() {
_, err = obtainCert(mainDomainAcmeClient, []string{"*" + string(mainDomainSuffix), string(mainDomainSuffix[1:])}, res, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB) _, err = obtainCert(mainDomainAcmeClient, []string{"*" + string(mainDomainSuffix), string(mainDomainSuffix[1:])}, res, "", dnsProvider, mainDomainSuffix, acmeUseRateLimits, certDB)
if err != nil { if err != nil {

View File

@@ -0,0 +1,17 @@
package certificates
import (
"testing"
"codeberg.org/codeberg/pages/server/database"
"github.com/stretchr/testify/assert"
)
func TestMockCert(t *testing.T) {
db, err := database.NewTmpDB()
assert.NoError(t, err)
cert := mockCert("example.com", "some error msg", "codeberg.page", db)
if assert.NotEmpty(t, cert) {
assert.NotEmpty(t, cert.Certificate)
}
}

View File

@@ -8,8 +8,8 @@ import (
type CertDB interface { type CertDB interface {
Close() error Close() error
Put(name string, cert *certificate.Resource) error Put(name string, cert *certificate.Resource) error
Get(name []byte) (*certificate.Resource, error) Get(name string) (*certificate.Resource, error)
Delete(key []byte) error Delete(key string) error
Compact() (pogreb.CompactionResult, error) Compact() (string, error)
Items() *pogreb.ItemIterator Items() *pogreb.ItemIterator
} }

55
server/database/mock.go Normal file
View File

@@ -0,0 +1,55 @@
package database
import (
"fmt"
"time"
"github.com/OrlovEvgeny/go-mcache"
"github.com/akrylysov/pogreb"
"github.com/go-acme/lego/v4/certificate"
)
var _ CertDB = tmpDB{}
type tmpDB struct {
intern *mcache.CacheDriver
ttl time.Duration
}
func (p tmpDB) Close() error {
_ = p.intern.Close()
return nil
}
func (p tmpDB) Put(name string, cert *certificate.Resource) error {
return p.intern.Set(name, cert, p.ttl)
}
func (p tmpDB) Get(name string) (*certificate.Resource, error) {
cert, has := p.intern.Get(name)
if !has {
return nil, fmt.Errorf("cert for '%s' not found", name)
}
return cert.(*certificate.Resource), nil
}
func (p tmpDB) Delete(key string) error {
p.intern.Remove(key)
return nil
}
func (p tmpDB) Compact() (string, error) {
p.intern.Truncate()
return "Truncate done", nil
}
func (p tmpDB) Items() *pogreb.ItemIterator {
panic("ItemIterator not implemented for tmpDB")
}
func NewTmpDB() (CertDB, error) {
return &tmpDB{
intern: mcache.New(),
ttl: time.Minute,
}, nil
}

View File

@@ -13,6 +13,8 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
var _ CertDB = aDB{}
type aDB struct { type aDB struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -33,9 +35,9 @@ func (p aDB) Put(name string, cert *certificate.Resource) error {
return p.intern.Put([]byte(name), resGob.Bytes()) return p.intern.Put([]byte(name), resGob.Bytes())
} }
func (p aDB) Get(name []byte) (*certificate.Resource, error) { func (p aDB) Get(name string) (*certificate.Resource, error) {
cert := &certificate.Resource{} cert := &certificate.Resource{}
resBytes, err := p.intern.Get(name) resBytes, err := p.intern.Get([]byte(name))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -48,12 +50,16 @@ func (p aDB) Get(name []byte) (*certificate.Resource, error) {
return cert, nil return cert, nil
} }
func (p aDB) Delete(key []byte) error { func (p aDB) Delete(key string) error {
return p.intern.Delete(key) return p.intern.Delete([]byte(key))
} }
func (p aDB) Compact() (pogreb.CompactionResult, error) { func (p aDB) Compact() (string, error) {
return p.intern.Compact() result, err := p.intern.Compact()
if err != nil {
return "", err
}
return fmt.Sprintf("%+v", result), nil
} }
func (p aDB) Items() *pogreb.ItemIterator { func (p aDB) Items() *pogreb.ItemIterator {
@@ -76,20 +82,6 @@ func (p aDB) sync() {
} }
} }
func (p aDB) compact() {
for {
err := p.intern.Sync()
if err != nil {
log.Err(err).Msg("Syncing cert database failed")
}
select {
case <-p.ctx.Done():
return
case <-time.After(p.syncInterval):
}
}
}
func New(path string) (CertDB, error) { func New(path string) (CertDB, error) {
if path == "" { if path == "" {
return nil, fmt.Errorf("path not set") return nil, fmt.Errorf("path not set")

134
server/gitea/client.go Normal file
View File

@@ -0,0 +1,134 @@
package gitea
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/valyala/fasthttp"
"github.com/valyala/fastjson"
)
const giteaAPIRepos = "/api/v1/repos/"
var ErrorNotFound = errors.New("not found")
type Client struct {
giteaRoot string
giteaAPIToken string
fastClient *fasthttp.Client
infoTimeout time.Duration
contentTimeout time.Duration
}
type FileResponse struct {
Exists bool
ETag []byte
MimeType string
Body []byte
}
// TODO: once golang v1.19 is min requirement, we can switch to 'JoinPath()' of 'net/url' package
func joinURL(baseURL string, paths ...string) string {
p := make([]string, 0, len(paths))
for i := range paths {
path := strings.TrimSpace(paths[i])
path = strings.Trim(path, "/")
if len(path) != 0 {
p = append(p, path)
}
}
return baseURL + "/" + strings.Join(p, "/")
}
func (f FileResponse) IsEmpty() bool { return len(f.Body) != 0 }
func NewClient(giteaRoot, giteaAPIToken string) (*Client, error) {
rootURL, err := url.Parse(giteaRoot)
giteaRoot = strings.Trim(rootURL.String(), "/")
return &Client{
giteaRoot: giteaRoot,
giteaAPIToken: giteaAPIToken,
infoTimeout: 5 * time.Second,
contentTimeout: 10 * time.Second,
fastClient: getFastHTTPClient(),
}, err
}
func (client *Client) GiteaRawContent(targetOwner, targetRepo, ref, resource string) ([]byte, error) {
url := joinURL(client.giteaRoot, giteaAPIRepos, targetOwner, targetRepo, "raw", resource+"?ref="+url.QueryEscape(ref))
res, err := client.do(client.contentTimeout, url)
if err != nil {
return nil, err
}
switch res.StatusCode() {
case fasthttp.StatusOK:
return res.Body(), nil
case fasthttp.StatusNotFound:
return nil, ErrorNotFound
default:
return nil, fmt.Errorf("unexpected status code '%d'", res.StatusCode())
}
}
func (client *Client) ServeRawContent(uri string) (*fasthttp.Response, error) {
url := joinURL(client.giteaRoot, giteaAPIRepos, uri)
res, err := client.do(client.contentTimeout, url)
if err != nil {
return nil, err
}
if err != nil {
return nil, err
}
switch res.StatusCode() {
case fasthttp.StatusOK:
return res, nil
case fasthttp.StatusNotFound:
return nil, ErrorNotFound
default:
return nil, fmt.Errorf("unexpected status code '%d'", res.StatusCode())
}
}
func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchName string) (time.Time, error) {
url := joinURL(client.giteaRoot, giteaAPIRepos, repoOwner, repoName, "branches", branchName)
res, err := client.do(client.infoTimeout, url)
if err != nil {
return time.Time{}, err
}
if res.StatusCode() != fasthttp.StatusOK {
return time.Time{}, fmt.Errorf("unexpected status code '%d'", res.StatusCode())
}
return time.Parse(time.RFC3339, fastjson.GetString(res.Body(), "commit", "timestamp"))
}
func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (string, error) {
url := joinURL(client.giteaRoot, giteaAPIRepos, repoOwner, repoName)
res, err := client.do(client.infoTimeout, url)
if err != nil {
return "", err
}
if res.StatusCode() != fasthttp.StatusOK {
return "", fmt.Errorf("unexpected status code '%d'", res.StatusCode())
}
return fastjson.GetString(res.Body(), "default_branch"), nil
}
func (client *Client) do(timeout time.Duration, url string) (*fasthttp.Response, error) {
req := fasthttp.AcquireRequest()
req.SetRequestURI(url)
req.Header.Set(fasthttp.HeaderAuthorization, "token "+client.giteaAPIToken)
res := fasthttp.AcquireResponse()
err := client.fastClient.DoTimeout(req, res, timeout)
return res, err
}

View File

@@ -0,0 +1,23 @@
package gitea
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
)
func TestJoinURL(t *testing.T) {
baseURL := ""
assert.EqualValues(t, "/", joinURL(baseURL))
assert.EqualValues(t, "/", joinURL(baseURL, "", ""))
baseURL = "http://wwow.url.com"
assert.EqualValues(t, "http://wwow.url.com/a/b/c/d", joinURL(baseURL, "a", "b/c/", "d"))
baseURL = "http://wow.url.com/subpath/2"
assert.EqualValues(t, "http://wow.url.com/subpath/2/content.pdf", joinURL(baseURL, "/content.pdf"))
assert.EqualValues(t, "http://wow.url.com/subpath/2/wonderful.jpg", joinURL(baseURL, "wonderful.jpg"))
assert.EqualValues(t, "http://wow.url.com/subpath/2/raw/wonderful.jpg?ref=main", joinURL(baseURL, "raw", "wonderful.jpg"+"?ref="+url.QueryEscape("main")))
assert.EqualValues(t, "http://wow.url.com/subpath/2/raw/wonderful.jpg%3Fref=main", joinURL(baseURL, "raw", "wonderful.jpg%3Fref=main"))
}

15
server/gitea/fasthttp.go Normal file
View File

@@ -0,0 +1,15 @@
package gitea
import (
"time"
"github.com/valyala/fasthttp"
)
func getFastHTTPClient() *fasthttp.Client {
return &fasthttp.Client{
MaxConnDuration: 60 * time.Second,
MaxConnWaitTimeout: 1000 * time.Millisecond,
MaxConnsPerHost: 128 * 16, // TODO: adjust bottlenecks for best performance with Gitea!
}
}

View File

@@ -4,25 +4,30 @@ import (
"bytes" "bytes"
"strings" "strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"codeberg.org/codeberg/pages/html" "codeberg.org/codeberg/pages/html"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/dns" "codeberg.org/codeberg/pages/server/dns"
"codeberg.org/codeberg/pages/server/gitea"
"codeberg.org/codeberg/pages/server/upstream" "codeberg.org/codeberg/pages/server/upstream"
"codeberg.org/codeberg/pages/server/utils" "codeberg.org/codeberg/pages/server/utils"
"codeberg.org/codeberg/pages/server/version"
) )
// Handler handles a single HTTP request to the web server. // Handler handles a single HTTP request to the web server.
func Handler(mainDomainSuffix, rawDomain []byte, func Handler(mainDomainSuffix, rawDomain []byte,
giteaRoot, rawInfoPage, giteaAPIToken string, giteaClient *gitea.Client,
giteaRoot, rawInfoPage string,
blacklistedPaths, allowedCorsDomains [][]byte, blacklistedPaths, allowedCorsDomains [][]byte,
dnsLookupCache, canonicalDomainCache, branchTimestampCache, fileResponseCache cache.SetGetKey) func(ctx *fasthttp.RequestCtx) { dnsLookupCache, canonicalDomainCache, branchTimestampCache, fileResponseCache cache.SetGetKey,
) func(ctx *fasthttp.RequestCtx) {
return func(ctx *fasthttp.RequestCtx) { return func(ctx *fasthttp.RequestCtx) {
log := log.With().Str("Handler", string(ctx.Request.Header.RequestURI())).Logger() log := log.With().Str("Handler", string(ctx.Request.Header.RequestURI())).Logger()
ctx.Response.Header.Set("Server", "Codeberg Pages") ctx.Response.Header.Set("Server", "CodebergPages/"+version.Version)
// Force new default from specification (since November 2020) - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#strict-origin-when-cross-origin // Force new default from specification (since November 2020) - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#strict-origin-when-cross-origin
ctx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin") ctx.Response.Header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
@@ -53,41 +58,45 @@ func Handler(mainDomainSuffix, rawDomain []byte,
} }
// Allow CORS for specified domains // Allow CORS for specified domains
allowCors := false
for _, allowedCorsDomain := range allowedCorsDomains {
if bytes.Equal(trimmedHost, allowedCorsDomain) {
allowCors = true
break
}
}
if allowCors {
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET, HEAD")
}
ctx.Response.Header.Set("Allow", "GET, HEAD, OPTIONS")
if ctx.IsOptions() { if ctx.IsOptions() {
allowCors := false
for _, allowedCorsDomain := range allowedCorsDomains {
if bytes.Equal(trimmedHost, allowedCorsDomain) {
allowCors = true
break
}
}
if allowCors {
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET, HEAD")
}
ctx.Response.Header.Set("Allow", "GET, HEAD, OPTIONS")
ctx.Response.Header.SetStatusCode(fasthttp.StatusNoContent) ctx.Response.Header.SetStatusCode(fasthttp.StatusNoContent)
return return
} }
// Prepare request information to Gitea // Prepare request information to Gitea
var targetOwner, targetRepo, targetBranch, targetPath string var targetOwner, targetRepo, targetBranch, targetPath string
var targetOptions = &upstream.Options{ targetOptions := &upstream.Options{
ForbiddenMimeTypes: map[string]struct{}{}, TryIndexPages: true,
TryIndexPages: true,
} }
// tryBranch checks if a branch exists and populates the target variables. If canonicalLink is non-empty, it will // tryBranch checks if a branch exists and populates the target variables. If canonicalLink is non-empty, it will
// also disallow search indexing and add a Link header to the canonical URL. // also disallow search indexing and add a Link header to the canonical URL.
var tryBranch = func(repo string, branch string, path []string, canonicalLink string) bool { tryBranch := func(log zerolog.Logger, repo, branch string, path []string, canonicalLink string) bool {
if repo == "" { if repo == "" {
log.Debug().Msg("tryBranch: repo == ''")
return false return false
} }
// Replace "~" to "/" so we can access branch that contains slash character
// Branch name cannot contain "~" so doing this is okay
branch = strings.ReplaceAll(branch, "~", "/")
// Check if the branch exists, otherwise treat it as a file path // Check if the branch exists, otherwise treat it as a file path
branchTimestampResult := upstream.GetBranchTimestamp(targetOwner, repo, branch, giteaRoot, giteaAPIToken, branchTimestampCache) branchTimestampResult := upstream.GetBranchTimestamp(giteaClient, targetOwner, repo, branch, branchTimestampCache)
if branchTimestampResult == nil { if branchTimestampResult == nil {
// branch doesn't exist log.Debug().Msg("tryBranch: branch doesn't exist")
return false return false
} }
@@ -107,6 +116,7 @@ func Handler(mainDomainSuffix, rawDomain []byte,
) )
} }
log.Debug().Msg("tryBranch: true")
return true return true
} }
@@ -116,7 +126,10 @@ func Handler(mainDomainSuffix, rawDomain []byte,
log.Debug().Msg("raw domain") log.Debug().Msg("raw domain")
targetOptions.TryIndexPages = false targetOptions.TryIndexPages = false
targetOptions.ForbiddenMimeTypes["text/html"] = struct{}{} if targetOptions.ForbiddenMimeTypes == nil {
targetOptions.ForbiddenMimeTypes = make(map[string]bool)
}
targetOptions.ForbiddenMimeTypes["text/html"] = true
targetOptions.DefaultMimeType = "text/plain; charset=utf-8" targetOptions.DefaultMimeType = "text/plain; charset=utf-8"
pathElements := strings.Split(string(bytes.Trim(ctx.Request.URI().Path(), "/")), "/") pathElements := strings.Split(string(bytes.Trim(ctx.Request.URI().Path(), "/")), "/")
@@ -131,13 +144,13 @@ func Handler(mainDomainSuffix, rawDomain []byte,
// raw.codeberg.org/example/myrepo/@main/index.html // raw.codeberg.org/example/myrepo/@main/index.html
if len(pathElements) > 2 && strings.HasPrefix(pathElements[2], "@") { if len(pathElements) > 2 && strings.HasPrefix(pathElements[2], "@") {
log.Debug().Msg("raw domain preparations, now trying with specified branch") log.Debug().Msg("raw domain preparations, now trying with specified branch")
if tryBranch(targetRepo, pathElements[2][1:], pathElements[3:], if tryBranch(log,
targetRepo, pathElements[2][1:], pathElements[3:],
giteaRoot+"/"+targetOwner+"/"+targetRepo+"/src/branch/%b/%p", giteaRoot+"/"+targetOwner+"/"+targetRepo+"/src/branch/%b/%p",
) { ) {
log.Debug().Msg("tryBranch, now trying upstream") log.Debug().Msg("tryBranch, now trying upstream 1")
tryUpstream(ctx, mainDomainSuffix, trimmedHost, tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
return return
} }
@@ -147,13 +160,13 @@ func Handler(mainDomainSuffix, rawDomain []byte,
} }
log.Debug().Msg("raw domain preparations, now trying with default branch") log.Debug().Msg("raw domain preparations, now trying with default branch")
tryBranch(targetRepo, "", pathElements[2:], tryBranch(log,
targetRepo, "", pathElements[2:],
giteaRoot+"/"+targetOwner+"/"+targetRepo+"/src/branch/%b/%p", giteaRoot+"/"+targetOwner+"/"+targetRepo+"/src/branch/%b/%p",
) )
log.Debug().Msg("tryBranch, now trying upstream") log.Debug().Msg("tryBranch, now trying upstream 2")
tryUpstream(ctx, mainDomainSuffix, trimmedHost, tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
return return
@@ -182,13 +195,13 @@ func Handler(mainDomainSuffix, rawDomain []byte,
} }
log.Debug().Msg("main domain preparations, now trying with specified repo & branch") log.Debug().Msg("main domain preparations, now trying with specified repo & branch")
if tryBranch(pathElements[0], pathElements[1][1:], pathElements[2:], if tryBranch(log,
pathElements[0], pathElements[1][1:], pathElements[2:],
"/"+pathElements[0]+"/%p", "/"+pathElements[0]+"/%p",
) { ) {
log.Debug().Msg("tryBranch, now trying upstream") log.Debug().Msg("tryBranch, now trying upstream 3")
tryUpstream(ctx, mainDomainSuffix, trimmedHost, tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
} else { } else {
html.ReturnErrorPage(ctx, fasthttp.StatusFailedDependency) html.ReturnErrorPage(ctx, fasthttp.StatusFailedDependency)
@@ -200,11 +213,11 @@ func Handler(mainDomainSuffix, rawDomain []byte,
// example.codeberg.page/@main/index.html // example.codeberg.page/@main/index.html
if strings.HasPrefix(pathElements[0], "@") { if strings.HasPrefix(pathElements[0], "@") {
log.Debug().Msg("main domain preparations, now trying with specified branch") log.Debug().Msg("main domain preparations, now trying with specified branch")
if tryBranch("pages", pathElements[0][1:], pathElements[1:], "/%p") { if tryBranch(log,
log.Debug().Msg("tryBranch, now trying upstream") "pages", pathElements[0][1:], pathElements[1:], "/%p") {
tryUpstream(ctx, mainDomainSuffix, trimmedHost, log.Debug().Msg("tryBranch, now trying upstream 4")
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
} else { } else {
html.ReturnErrorPage(ctx, fasthttp.StatusFailedDependency) html.ReturnErrorPage(ctx, fasthttp.StatusFailedDependency)
@@ -216,11 +229,11 @@ func Handler(mainDomainSuffix, rawDomain []byte,
// example.codeberg.page/myrepo/index.html // example.codeberg.page/myrepo/index.html
// example.codeberg.page/pages/... is not allowed here. // example.codeberg.page/pages/... is not allowed here.
log.Debug().Msg("main domain preparations, now trying with specified repo") log.Debug().Msg("main domain preparations, now trying with specified repo")
if pathElements[0] != "pages" && tryBranch(pathElements[0], "pages", pathElements[1:], "") { if pathElements[0] != "pages" && tryBranch(log,
log.Debug().Msg("tryBranch, now trying upstream") pathElements[0], "pages", pathElements[1:], "") {
tryUpstream(ctx, mainDomainSuffix, trimmedHost, log.Debug().Msg("tryBranch, now trying upstream 5")
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
return return
} }
@@ -228,11 +241,11 @@ func Handler(mainDomainSuffix, rawDomain []byte,
// Try to use the "pages" repo on its default branch // Try to use the "pages" repo on its default branch
// example.codeberg.page/index.html // example.codeberg.page/index.html
log.Debug().Msg("main domain preparations, now trying with default repo/branch") log.Debug().Msg("main domain preparations, now trying with default repo/branch")
if tryBranch("pages", "", pathElements, "") { if tryBranch(log,
log.Debug().Msg("tryBranch, now trying upstream") "pages", "", pathElements, "") {
tryUpstream(ctx, mainDomainSuffix, trimmedHost, log.Debug().Msg("tryBranch, now trying upstream 6")
tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
return return
} }
@@ -260,8 +273,9 @@ func Handler(mainDomainSuffix, rawDomain []byte,
// Try to use the given repo on the given branch or the default branch // Try to use the given repo on the given branch or the default branch
log.Debug().Msg("custom domain preparations, now trying with details from DNS") log.Debug().Msg("custom domain preparations, now trying with details from DNS")
if tryBranch(targetRepo, targetBranch, pathElements, canonicalLink) { if tryBranch(log,
canonicalDomain, valid := upstream.CheckCanonicalDomain(targetOwner, targetRepo, targetBranch, trimmedHostStr, string(mainDomainSuffix), giteaRoot, giteaAPIToken, canonicalDomainCache) targetRepo, targetBranch, pathElements, canonicalLink) {
canonicalDomain, valid := upstream.CheckCanonicalDomain(giteaClient, targetOwner, targetRepo, targetBranch, trimmedHostStr, string(mainDomainSuffix), canonicalDomainCache)
if !valid { if !valid {
html.ReturnErrorPage(ctx, fasthttp.StatusMisdirectedRequest) html.ReturnErrorPage(ctx, fasthttp.StatusMisdirectedRequest)
return return
@@ -277,10 +291,9 @@ func Handler(mainDomainSuffix, rawDomain []byte,
return return
} }
log.Debug().Msg("tryBranch, now trying upstream") log.Debug().Msg("tryBranch, now trying upstream 7")
tryUpstream(ctx, mainDomainSuffix, trimmedHost, tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost,
targetOptions, targetOwner, targetRepo, targetBranch, targetPath, targetOptions, targetOwner, targetRepo, targetBranch, targetPath,
giteaRoot, giteaAPIToken,
canonicalDomainCache, branchTimestampCache, fileResponseCache) canonicalDomainCache, branchTimestampCache, fileResponseCache)
return return
} }

View File

@@ -2,20 +2,22 @@ package server
import ( import (
"fmt" "fmt"
"github.com/valyala/fasthttp"
"testing" "testing"
"time" "time"
"github.com/valyala/fasthttp"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/gitea"
) )
func TestHandlerPerformance(t *testing.T) { func TestHandlerPerformance(t *testing.T) {
giteaRoot := "https://codeberg.org"
giteaClient, _ := gitea.NewClient(giteaRoot, "")
testHandler := Handler( testHandler := Handler(
[]byte("codeberg.page"), []byte("codeberg.page"), []byte("raw.codeberg.org"),
[]byte("raw.codeberg.org"), giteaClient,
"https://codeberg.org", giteaRoot, "https://docs.codeberg.org/pages/raw-content/",
"https://docs.codeberg.org/pages/raw-content/",
"",
[][]byte{[]byte("/.well-known/acme-challenge/")}, [][]byte{[]byte("/.well-known/acme-challenge/")},
[][]byte{[]byte("raw.codeberg.org"), []byte("fonts.codeberg.org"), []byte("design.codeberg.org")}, [][]byte{[]byte("raw.codeberg.org"), []byte("fonts.codeberg.org"), []byte("design.codeberg.org")},
cache.NewKeyValueCache(), cache.NewKeyValueCache(),
@@ -24,46 +26,26 @@ func TestHandlerPerformance(t *testing.T) {
cache.NewKeyValueCache(), cache.NewKeyValueCache(),
) )
ctx := &fasthttp.RequestCtx{ testCase := func(uri string, status int) {
Request: *fasthttp.AcquireRequest(), ctx := &fasthttp.RequestCtx{
Response: *fasthttp.AcquireResponse(), Request: *fasthttp.AcquireRequest(),
} Response: *fasthttp.AcquireResponse(),
ctx.Request.SetRequestURI("http://mondstern.codeberg.page/") }
fmt.Printf("Start: %v\n", time.Now()) ctx.Request.SetRequestURI(uri)
start := time.Now() fmt.Printf("Start: %v\n", time.Now())
testHandler(ctx) start := time.Now()
end := time.Now() testHandler(ctx)
fmt.Printf("Done: %v\n", time.Now()) end := time.Now()
if ctx.Response.StatusCode() != 200 || len(ctx.Response.Body()) < 2048 { fmt.Printf("Done: %v\n", time.Now())
t.Errorf("request failed with status code %d and body length %d", ctx.Response.StatusCode(), len(ctx.Response.Body())) if ctx.Response.StatusCode() != status {
} else { t.Errorf("request failed with status code %d", ctx.Response.StatusCode())
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds()) } else {
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
}
} }
ctx.Response.Reset() testCase("https://mondstern.codeberg.page/", 424) // TODO: expect 200
ctx.Response.ResetBody() testCase("https://mondstern.codeberg.page/", 424) // TODO: expect 200
fmt.Printf("Start: %v\n", time.Now()) testCase("https://example.momar.xyz/", 424) // TODO: expect 200
start = time.Now() testCase("https://codeberg.page/", 424) // TODO: expect 200
testHandler(ctx)
end = time.Now()
fmt.Printf("Done: %v\n", time.Now())
if ctx.Response.StatusCode() != 200 || len(ctx.Response.Body()) < 2048 {
t.Errorf("request failed with status code %d and body length %d", ctx.Response.StatusCode(), len(ctx.Response.Body()))
} else {
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
}
ctx.Response.Reset()
ctx.Response.ResetBody()
ctx.Request.SetRequestURI("http://example.momar.xyz/")
fmt.Printf("Start: %v\n", time.Now())
start = time.Now()
testHandler(ctx)
end = time.Now()
fmt.Printf("Done: %v\n", time.Now())
if ctx.Response.StatusCode() != 200 || len(ctx.Response.Body()) < 1 {
t.Errorf("request failed with status code %d and body length %d", ctx.Response.StatusCode(), len(ctx.Response.Body()))
} else {
t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds())
}
} }

View File

@@ -5,12 +5,20 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/rs/zerolog/log"
"github.com/valyala/fasthttp" "github.com/valyala/fasthttp"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/utils" "codeberg.org/codeberg/pages/server/utils"
) )
type fasthttpLogger struct{}
func (fasthttpLogger) Printf(format string, args ...interface{}) {
log.Printf("[FASTHTTP] "+format, args...)
}
func SetupServer(handler fasthttp.RequestHandler) *fasthttp.Server { func SetupServer(handler fasthttp.RequestHandler) *fasthttp.Server {
// Enable compression by wrapping the handler with the compression function provided by FastHTTP // Enable compression by wrapping the handler with the compression function provided by FastHTTP
compressedHandler := fasthttp.CompressHandlerBrotliLevel(handler, fasthttp.CompressBrotliBestSpeed, fasthttp.CompressBestSpeed) compressedHandler := fasthttp.CompressHandlerBrotliLevel(handler, fasthttp.CompressBrotliBestSpeed, fasthttp.CompressBestSpeed)
@@ -18,12 +26,10 @@ func SetupServer(handler fasthttp.RequestHandler) *fasthttp.Server {
return &fasthttp.Server{ return &fasthttp.Server{
Handler: compressedHandler, Handler: compressedHandler,
DisablePreParseMultipartForm: true, DisablePreParseMultipartForm: true,
MaxRequestBodySize: 0,
NoDefaultServerHeader: true, NoDefaultServerHeader: true,
NoDefaultDate: true, NoDefaultDate: true,
ReadTimeout: 30 * time.Second, // needs to be this high for ACME certificates with ZeroSSL & HTTP-01 challenge ReadTimeout: 30 * time.Second, // needs to be this high for ACME certificates with ZeroSSL & HTTP-01 challenge
Concurrency: 1024 * 32, // TODO: adjust bottlenecks for best performance with Gitea! Logger: fasthttpLogger{},
MaxConnsPerIP: 100,
} }
} }

View File

@@ -8,22 +8,22 @@ import (
"codeberg.org/codeberg/pages/html" "codeberg.org/codeberg/pages/html"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/gitea"
"codeberg.org/codeberg/pages/server/upstream" "codeberg.org/codeberg/pages/server/upstream"
) )
// tryUpstream forwards the target request to the Gitea API, and shows an error page on failure. // tryUpstream forwards the target request to the Gitea API, and shows an error page on failure.
func tryUpstream(ctx *fasthttp.RequestCtx, func tryUpstream(ctx *fasthttp.RequestCtx, giteaClient *gitea.Client,
mainDomainSuffix, trimmedHost []byte, mainDomainSuffix, trimmedHost []byte,
targetOptions *upstream.Options, targetOptions *upstream.Options,
targetOwner, targetRepo, targetBranch, targetPath, targetOwner, targetRepo, targetBranch, targetPath string,
giteaRoot, giteaAPIToken string,
canonicalDomainCache, branchTimestampCache, fileResponseCache cache.SetGetKey) {
canonicalDomainCache, branchTimestampCache, fileResponseCache cache.SetGetKey,
) {
// check if a canonical domain exists on a request on MainDomain // check if a canonical domain exists on a request on MainDomain
if bytes.HasSuffix(trimmedHost, mainDomainSuffix) { if bytes.HasSuffix(trimmedHost, mainDomainSuffix) {
canonicalDomain, _ := upstream.CheckCanonicalDomain(targetOwner, targetRepo, targetBranch, "", string(mainDomainSuffix), giteaRoot, giteaAPIToken, canonicalDomainCache) canonicalDomain, _ := upstream.CheckCanonicalDomain(giteaClient, targetOwner, targetRepo, targetBranch, "", string(mainDomainSuffix), canonicalDomainCache)
if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], string(mainDomainSuffix)) { if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], string(mainDomainSuffix)) {
canonicalPath := string(ctx.RequestURI()) canonicalPath := string(ctx.RequestURI())
if targetRepo != "pages" { if targetRepo != "pages" {
@@ -43,7 +43,7 @@ func tryUpstream(ctx *fasthttp.RequestCtx,
targetOptions.TargetPath = targetPath targetOptions.TargetPath = targetPath
// Try to request the file from the Gitea API // Try to request the file from the Gitea API
if !targetOptions.Upstream(ctx, giteaRoot, giteaAPIToken, branchTimestampCache, fileResponseCache) { if !targetOptions.Upstream(ctx, giteaClient, branchTimestampCache, fileResponseCache) {
html.ReturnErrorPage(ctx, ctx.Response.StatusCode()) html.ReturnErrorPage(ctx, ctx.Response.StatusCode())
} }
} }

View File

@@ -12,6 +12,7 @@ var branchExistenceCacheTimeout = 5 * time.Minute
// fileCacheTimeout specifies the timeout for the file content cache - you might want to make this quite long, depending // fileCacheTimeout specifies the timeout for the file content cache - you might want to make this quite long, depending
// on your available memory. // on your available memory.
// TODO: move as option into cache interface
var fileCacheTimeout = 5 * time.Minute var fileCacheTimeout = 5 * time.Minute
// fileCacheSizeLimit limits the maximum file size that will be cached, and is set to 1 MB by default. // fileCacheSizeLimit limits the maximum file size that will be cached, and is set to 1 MB by default.
@@ -19,3 +20,5 @@ var fileCacheSizeLimit = 1024 * 1024
// canonicalDomainCacheTimeout specifies the timeout for the canonical domain cache. // canonicalDomainCacheTimeout specifies the timeout for the canonical domain cache.
var canonicalDomainCacheTimeout = 15 * time.Minute var canonicalDomainCacheTimeout = 15 * time.Minute
const canonicalDomainConfig = ".domains"

View File

@@ -3,15 +3,16 @@ package upstream
import ( import (
"strings" "strings"
"github.com/valyala/fasthttp"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/gitea"
) )
// CheckCanonicalDomain returns the canonical domain specified in the repo (using the `.domains` file). // CheckCanonicalDomain returns the canonical domain specified in the repo (using the `.domains` file).
func CheckCanonicalDomain(targetOwner, targetRepo, targetBranch, actualDomain, mainDomainSuffix, giteaRoot, giteaAPIToken string, canonicalDomainCache cache.SetGetKey) (string, bool) { func CheckCanonicalDomain(giteaClient *gitea.Client, targetOwner, targetRepo, targetBranch, actualDomain, mainDomainSuffix string, canonicalDomainCache cache.SetGetKey) (string, bool) {
domains := []string{} var (
valid := false domains []string
valid bool
)
if cachedValue, ok := canonicalDomainCache.Get(targetOwner + "/" + targetRepo + "/" + targetBranch); ok { if cachedValue, ok := canonicalDomainCache.Get(targetOwner + "/" + targetRepo + "/" + targetBranch); ok {
domains = cachedValue.([]string) domains = cachedValue.([]string)
for _, domain := range domains { for _, domain := range domains {
@@ -21,13 +22,9 @@ func CheckCanonicalDomain(targetOwner, targetRepo, targetBranch, actualDomain, m
} }
} }
} else { } else {
req := fasthttp.AcquireRequest() body, err := giteaClient.GiteaRawContent(targetOwner, targetRepo, targetBranch, canonicalDomainConfig)
req.SetRequestURI(giteaRoot + "/api/v1/repos/" + targetOwner + "/" + targetRepo + "/raw/" + targetBranch + "/.domains" + "?access_token=" + giteaAPIToken) if err == nil {
res := fasthttp.AcquireResponse() for _, domain := range strings.Split(string(body), "\n") {
err := client.Do(req, res)
if err == nil && res.StatusCode() == fasthttp.StatusOK {
for _, domain := range strings.Split(string(res.Body()), "\n") {
domain = strings.ToLower(domain) domain = strings.ToLower(domain)
domain = strings.TrimSpace(domain) domain = strings.TrimSpace(domain)
domain = strings.TrimPrefix(domain, "http://") domain = strings.TrimPrefix(domain, "http://")

View File

@@ -1,12 +1,14 @@
package upstream package upstream
import ( import (
"mime"
"path"
"strconv"
"strings"
"time" "time"
"github.com/valyala/fasthttp"
"github.com/valyala/fastjson"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/gitea"
) )
type branchTimestamp struct { type branchTimestamp struct {
@@ -16,40 +18,55 @@ type branchTimestamp struct {
// GetBranchTimestamp finds the default branch (if branch is "") and returns the last modification time of the branch // GetBranchTimestamp finds the default branch (if branch is "") and returns the last modification time of the branch
// (or nil if the branch doesn't exist) // (or nil if the branch doesn't exist)
func GetBranchTimestamp(owner, repo, branch, giteaRoot, giteaApiToken string, branchTimestampCache cache.SetGetKey) *branchTimestamp { func GetBranchTimestamp(giteaClient *gitea.Client, owner, repo, branch string, branchTimestampCache cache.SetGetKey) *branchTimestamp {
if result, ok := branchTimestampCache.Get(owner + "/" + repo + "/" + branch); ok { if result, ok := branchTimestampCache.Get(owner + "/" + repo + "/" + branch); ok {
if result == nil { if result == nil {
return nil return nil
} }
return result.(*branchTimestamp) return result.(*branchTimestamp)
} }
result := &branchTimestamp{} result := &branchTimestamp{
result.Branch = branch Branch: branch,
if branch == "" { }
if len(branch) == 0 {
// Get default branch // Get default branch
var body = make([]byte, 0) defaultBranch, err := giteaClient.GiteaGetRepoDefaultBranch(owner, repo)
// TODO: use header for API key? if err != nil {
status, body, err := fasthttp.GetTimeout(body, giteaRoot+"/api/v1/repos/"+owner+"/"+repo+"?access_token="+giteaApiToken, 5*time.Second) _ = branchTimestampCache.Set(owner+"/"+repo+"/", nil, defaultBranchCacheTimeout)
if err != nil || status != 200 {
_ = branchTimestampCache.Set(owner+"/"+repo+"/"+branch, nil, defaultBranchCacheTimeout)
return nil return nil
} }
result.Branch = fastjson.GetString(body, "default_branch") result.Branch = defaultBranch
} }
var body = make([]byte, 0) timestamp, err := giteaClient.GiteaGetRepoBranchTimestamp(owner, repo, result.Branch)
status, body, err := fasthttp.GetTimeout(body, giteaRoot+"/api/v1/repos/"+owner+"/"+repo+"/branches/"+branch+"?access_token="+giteaApiToken, 5*time.Second) if err != nil {
if err != nil || status != 200 {
return nil return nil
} }
result.Timestamp = timestamp
result.Timestamp, _ = time.Parse(time.RFC3339, fastjson.GetString(body, "commit", "timestamp"))
_ = branchTimestampCache.Set(owner+"/"+repo+"/"+branch, result, branchExistenceCacheTimeout) _ = branchTimestampCache.Set(owner+"/"+repo+"/"+branch, result, branchExistenceCacheTimeout)
return result return result
} }
type fileResponse struct { func (o *Options) getMimeTypeByExtension() string {
exists bool if o.ForbiddenMimeTypes == nil {
mimeType string o.ForbiddenMimeTypes = make(map[string]bool)
body []byte }
mimeType := mime.TypeByExtension(path.Ext(o.TargetPath))
mimeTypeSplit := strings.SplitN(mimeType, ";", 2)
if o.ForbiddenMimeTypes[mimeTypeSplit[0]] || mimeType == "" {
if o.DefaultMimeType != "" {
mimeType = o.DefaultMimeType
} else {
mimeType = "application/octet-stream"
}
}
return mimeType
}
func (o *Options) generateUri() string {
return path.Join(o.TargetOwner, o.TargetRepo, "raw", o.TargetBranch, o.TargetPath)
}
func (o *Options) timestamp() string {
return strconv.FormatInt(o.BranchTimestamp.Unix(), 10)
} }

View File

@@ -2,11 +2,9 @@ package upstream
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"mime"
"path"
"strconv"
"strings" "strings"
"time" "time"
@@ -15,6 +13,7 @@ import (
"codeberg.org/codeberg/pages/html" "codeberg.org/codeberg/pages/html"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/gitea"
) )
// upstreamIndexPages lists pages that may be considered as index pages for directories. // upstreamIndexPages lists pages that may be considered as index pages for directories.
@@ -22,6 +21,11 @@ var upstreamIndexPages = []string{
"index.html", "index.html",
} }
// upstreamNotFoundPages lists pages that may be considered as custom 404 Not Found pages.
var upstreamNotFoundPages = []string{
"404.html",
}
// Options provides various options for the upstream request. // Options provides various options for the upstream request.
type Options struct { type Options struct {
TargetOwner, TargetOwner,
@@ -30,7 +34,7 @@ type Options struct {
TargetPath, TargetPath,
DefaultMimeType string DefaultMimeType string
ForbiddenMimeTypes map[string]struct{} ForbiddenMimeTypes map[string]bool
TryIndexPages bool TryIndexPages bool
BranchTimestamp time.Time BranchTimestamp time.Time
// internal // internal
@@ -38,24 +42,13 @@ type Options struct {
redirectIfExists string redirectIfExists string
} }
var client = fasthttp.Client{
ReadTimeout: 10 * time.Second,
MaxConnDuration: 60 * time.Second,
MaxConnWaitTimeout: 1000 * time.Millisecond,
MaxConnsPerHost: 128 * 16, // TODO: adjust bottlenecks for best performance with Gitea!
}
// Upstream requests a file from the Gitea API at GiteaRoot and writes it to the request context. // Upstream requests a file from the Gitea API at GiteaRoot and writes it to the request context.
func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaRoot, giteaAPIToken string, branchTimestampCache, fileResponseCache cache.SetGetKey) (final bool) { func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaClient *gitea.Client, branchTimestampCache, fileResponseCache cache.SetGetKey) (final bool) {
log := log.With().Strs("upstream", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath}).Logger() log := log.With().Strs("upstream", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath}).Logger()
if o.ForbiddenMimeTypes == nil {
o.ForbiddenMimeTypes = map[string]struct{}{}
}
// Check if the branch exists and when it was modified // Check if the branch exists and when it was modified
if o.BranchTimestamp.IsZero() { if o.BranchTimestamp.IsZero() {
branch := GetBranchTimestamp(o.TargetOwner, o.TargetRepo, o.TargetBranch, giteaRoot, giteaAPIToken, branchTimestampCache) branch := GetBranchTimestamp(giteaClient, o.TargetOwner, o.TargetRepo, o.TargetBranch, branchTimestampCache)
if branch == nil { if branch == nil {
html.ReturnErrorPage(ctx, fasthttp.StatusFailedDependency) html.ReturnErrorPage(ctx, fasthttp.StatusFailedDependency)
@@ -80,24 +73,19 @@ func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaRoot, giteaAPIToken st
log.Debug().Msg("preparations") log.Debug().Msg("preparations")
// Make a GET request to the upstream URL // Make a GET request to the upstream URL
uri := o.TargetOwner + "/" + o.TargetRepo + "/raw/" + o.TargetBranch + "/" + o.TargetPath uri := o.generateUri()
var req *fasthttp.Request
var res *fasthttp.Response var res *fasthttp.Response
var cachedResponse fileResponse var cachedResponse gitea.FileResponse
var err error var err error
if cachedValue, ok := fileResponseCache.Get(uri + "?timestamp=" + strconv.FormatInt(o.BranchTimestamp.Unix(), 10)); ok && len(cachedValue.(fileResponse).body) > 0 { if cachedValue, ok := fileResponseCache.Get(uri + "?timestamp=" + o.timestamp()); ok && !cachedValue.(gitea.FileResponse).IsEmpty() {
cachedResponse = cachedValue.(fileResponse) cachedResponse = cachedValue.(gitea.FileResponse)
} else { } else {
req = fasthttp.AcquireRequest() res, err = giteaClient.ServeRawContent(uri)
req.SetRequestURI(giteaRoot + "/api/v1/repos/" + uri + "?access_token=" + giteaAPIToken)
res = fasthttp.AcquireResponse()
res.SetBodyStream(&strings.Reader{}, -1)
err = client.Do(req, res)
} }
log.Debug().Msg("acquisition") log.Debug().Msg("acquisition")
// Handle errors // Handle errors
if (res == nil && !cachedResponse.exists) || (res != nil && res.StatusCode() == fasthttp.StatusNotFound) { if (err != nil && errors.Is(err, gitea.ErrorNotFound)) || (res == nil && !cachedResponse.Exists) {
if o.TryIndexPages { if o.TryIndexPages {
// copy the o struct & try if an index page exists // copy the o struct & try if an index page exists
optionsForIndexPages := *o optionsForIndexPages := *o
@@ -105,9 +93,9 @@ func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaRoot, giteaAPIToken st
optionsForIndexPages.appendTrailingSlash = true optionsForIndexPages.appendTrailingSlash = true
for _, indexPage := range upstreamIndexPages { for _, indexPage := range upstreamIndexPages {
optionsForIndexPages.TargetPath = strings.TrimSuffix(o.TargetPath, "/") + "/" + indexPage optionsForIndexPages.TargetPath = strings.TrimSuffix(o.TargetPath, "/") + "/" + indexPage
if optionsForIndexPages.Upstream(ctx, giteaRoot, giteaAPIToken, branchTimestampCache, fileResponseCache) { if optionsForIndexPages.Upstream(ctx, giteaClient, branchTimestampCache, fileResponseCache) {
_ = fileResponseCache.Set(uri+"?timestamp="+strconv.FormatInt(o.BranchTimestamp.Unix(), 10), fileResponse{ _ = fileResponseCache.Set(uri+"?timestamp="+o.timestamp(), gitea.FileResponse{
exists: false, Exists: false,
}, fileCacheTimeout) }, fileCacheTimeout)
return true return true
} }
@@ -116,24 +104,39 @@ func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaRoot, giteaAPIToken st
optionsForIndexPages.appendTrailingSlash = false optionsForIndexPages.appendTrailingSlash = false
optionsForIndexPages.redirectIfExists = strings.TrimSuffix(string(ctx.Request.URI().Path()), "/") + ".html" optionsForIndexPages.redirectIfExists = strings.TrimSuffix(string(ctx.Request.URI().Path()), "/") + ".html"
optionsForIndexPages.TargetPath = o.TargetPath + ".html" optionsForIndexPages.TargetPath = o.TargetPath + ".html"
if optionsForIndexPages.Upstream(ctx, giteaRoot, giteaAPIToken, branchTimestampCache, fileResponseCache) { if optionsForIndexPages.Upstream(ctx, giteaClient, branchTimestampCache, fileResponseCache) {
_ = fileResponseCache.Set(uri+"?timestamp="+strconv.FormatInt(o.BranchTimestamp.Unix(), 10), fileResponse{ _ = fileResponseCache.Set(uri+"?timestamp="+o.timestamp(), gitea.FileResponse{
exists: false, Exists: false,
}, fileCacheTimeout) }, fileCacheTimeout)
return true return true
} }
} }
ctx.Response.SetStatusCode(fasthttp.StatusNotFound) ctx.Response.SetStatusCode(fasthttp.StatusNotFound)
if o.TryIndexPages {
// copy the o struct & try if a not found page exists
optionsForNotFoundPages := *o
optionsForNotFoundPages.TryIndexPages = false
optionsForNotFoundPages.appendTrailingSlash = false
for _, notFoundPage := range upstreamNotFoundPages {
optionsForNotFoundPages.TargetPath = "/" + notFoundPage
if optionsForNotFoundPages.Upstream(ctx, giteaClient, branchTimestampCache, fileResponseCache) {
_ = fileResponseCache.Set(uri+"?timestamp="+o.timestamp(), gitea.FileResponse{
Exists: false,
}, fileCacheTimeout)
return true
}
}
}
if res != nil { if res != nil {
// Update cache if the request is fresh // Update cache if the request is fresh
_ = fileResponseCache.Set(uri+"?timestamp="+strconv.FormatInt(o.BranchTimestamp.Unix(), 10), fileResponse{ _ = fileResponseCache.Set(uri+"?timestamp="+o.timestamp(), gitea.FileResponse{
exists: false, Exists: false,
}, fileCacheTimeout) }, fileCacheTimeout)
} }
return false return false
} }
if res != nil && (err != nil || res.StatusCode() != fasthttp.StatusOK) { if res != nil && (err != nil || res.StatusCode() != fasthttp.StatusOK) {
fmt.Printf("Couldn't fetch contents from \"%s\": %s (status code %d)\n", req.RequestURI(), err, res.StatusCode()) fmt.Printf("Couldn't fetch contents from \"%s\": %s (status code %d)\n", uri, err, res.StatusCode())
html.ReturnErrorPage(ctx, fasthttp.StatusInternalServerError) html.ReturnErrorPage(ctx, fasthttp.StatusInternalServerError)
return true return true
} }
@@ -155,19 +158,21 @@ func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaRoot, giteaAPIToken st
log.Debug().Msg("error handling") log.Debug().Msg("error handling")
// Set the MIME type // Set the MIME type
mimeType := mime.TypeByExtension(path.Ext(o.TargetPath)) mimeType := o.getMimeTypeByExtension()
mimeTypeSplit := strings.SplitN(mimeType, ";", 2)
if _, ok := o.ForbiddenMimeTypes[mimeTypeSplit[0]]; ok || mimeType == "" {
if o.DefaultMimeType != "" {
mimeType = o.DefaultMimeType
} else {
mimeType = "application/octet-stream"
}
}
ctx.Response.Header.SetContentType(mimeType) ctx.Response.Header.SetContentType(mimeType)
// Everything's okay so far // Set ETag
ctx.Response.SetStatusCode(fasthttp.StatusOK) if cachedResponse.Exists {
ctx.Response.Header.SetBytesV(fasthttp.HeaderETag, cachedResponse.ETag)
} else if res != nil {
cachedResponse.ETag = res.Header.Peek(fasthttp.HeaderETag)
ctx.Response.Header.SetBytesV(fasthttp.HeaderETag, cachedResponse.ETag)
}
if ctx.Response.StatusCode() != fasthttp.StatusNotFound {
// Everything's okay so far
ctx.Response.SetStatusCode(fasthttp.StatusOK)
}
ctx.Response.Header.SetLastModified(o.BranchTimestamp) ctx.Response.Header.SetLastModified(o.BranchTimestamp)
log.Debug().Msg("response preparations") log.Debug().Msg("response preparations")
@@ -176,26 +181,29 @@ func (o *Options) Upstream(ctx *fasthttp.RequestCtx, giteaRoot, giteaAPIToken st
var cacheBodyWriter bytes.Buffer var cacheBodyWriter bytes.Buffer
if res != nil { if res != nil {
if res.Header.ContentLength() > fileCacheSizeLimit { if res.Header.ContentLength() > fileCacheSizeLimit {
// fasthttp else will set "Content-Length: 0"
ctx.Response.SetBodyStream(&strings.Reader{}, -1)
err = res.BodyWriteTo(ctx.Response.BodyWriter()) err = res.BodyWriteTo(ctx.Response.BodyWriter())
} else { } else {
// TODO: cache is half-empty if request is cancelled - does the ctx.Err() below do the trick? // TODO: cache is half-empty if request is cancelled - does the ctx.Err() below do the trick?
err = res.BodyWriteTo(io.MultiWriter(ctx.Response.BodyWriter(), &cacheBodyWriter)) err = res.BodyWriteTo(io.MultiWriter(ctx.Response.BodyWriter(), &cacheBodyWriter))
} }
} else { } else {
_, err = ctx.Write(cachedResponse.body) _, err = ctx.Write(cachedResponse.Body)
} }
if err != nil { if err != nil {
fmt.Printf("Couldn't write body for \"%s\": %s\n", req.RequestURI(), err) fmt.Printf("Couldn't write body for \"%s\": %s\n", uri, err)
html.ReturnErrorPage(ctx, fasthttp.StatusInternalServerError) html.ReturnErrorPage(ctx, fasthttp.StatusInternalServerError)
return true return true
} }
log.Debug().Msg("response") log.Debug().Msg("response")
if res != nil && ctx.Err() == nil { if res != nil && res.Header.ContentLength() <= fileCacheSizeLimit && ctx.Err() == nil {
cachedResponse.exists = true cachedResponse.Exists = true
cachedResponse.mimeType = mimeType cachedResponse.MimeType = mimeType
cachedResponse.body = cacheBodyWriter.Bytes() cachedResponse.Body = cacheBodyWriter.Bytes()
_ = fileResponseCache.Set(uri+"?timestamp="+strconv.FormatInt(o.BranchTimestamp.Unix(), 10), cachedResponse, fileCacheTimeout) _ = fileResponseCache.Set(uri+"?timestamp="+o.timestamp(), cachedResponse, fileCacheTimeout)
} }
return true return true

View File

@@ -0,0 +1,3 @@
package version
var Version string = "dev"