Compare commits

..

No commits in common. "main" and "v5.1" have entirely different histories.
main ... v5.1

60 changed files with 1379 additions and 4431 deletions

View file

@ -1,11 +0,0 @@
ACME_API=https://acme.mock.directory
ACME_ACCEPT_TERMS=true
PAGES_DOMAIN=localhost.mock.directory
RAW_DOMAIN=raw.localhost.mock.directory
PAGES_BRANCHES=pages,master,main
GITEA_ROOT=https://codeberg.org
PORT=4430
HTTP_PORT=8880
ENABLE_HTTP_SERVER=true
LOG_LEVEL=trace
ACME_ACCOUNT_CONFIG=integration/acme-account.json

1
.envrc
View file

@ -1 +0,0 @@
use_flake

View file

@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Codeberg Pages Usage Support
url: https://codeberg.org/Codeberg/Community/issues/
about: If you need help with configuring Codeberg Pages on codeberg.org, please go here.

2
.gitignore vendored
View file

@ -8,5 +8,3 @@ vendor/
pages pages
certs.sqlite certs.sqlite
.bash_history .bash_history
pkg/
.direnv/

View file

@ -1,8 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2,
"endOfLine": "lf"
}

26
.vscode/launch.json vendored
View file

@ -1,26 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch PagesServer",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/main.go",
"args": ["sqlite", "sqlite_unlock_notify", "netgo"],
"envFile": "${workspaceFolder}/.env-dev"
},
{
"name": "Launch PagesServer integration test",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/integration/main_test.go",
"args": ["codeberg.org/codeberg/pages/integration/..."],
"buildFlags": ["-tags", "'integration sqlite sqlite_unlock_notify netgo'"]
}
]
}

114
.woodpecker.yml Normal file
View file

@ -0,0 +1,114 @@
when:
branch: main
steps:
# use vendor to cache dependencies
vendor:
image: golang:1.21
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
editor-config:
group: compliant
image: mstruebing/editorconfig-checker
build:
group: compliant
image: codeberg.org/6543/docker-images/golang_just
commands:
- go version
- just build
when:
event: [ "pull_request", "push" ]
docker-dryrun:
group: compliant
image: plugins/kaniko
settings:
dockerfile: Dockerfile
no_push: true
tags: latest
when:
event: [ "pull_request", "push" ]
path: Dockerfile
build-tag:
group: compliant
image: codeberg.org/6543/docker-images/golang_just
commands:
- go version
- just build-tag ${CI_COMMIT_TAG##v}
when:
event: [ "tag" ]
test:
group: test
image: codeberg.org/6543/docker-images/golang_just
commands:
- just test
integration-tests:
group: test
image: codeberg.org/6543/docker-images/golang_just
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" ]
docker-next:
image: plugins/kaniko
settings:
registry: codeberg.org
dockerfile: Dockerfile
repo: codeberg.org/codeberg/pages-server
tags: next
username:
from_secret: bot_user
password:
from_secret: bot_token
when:
event: [ "push" ]
branch: ${CI_REPO_DEFAULT_BRANCH}
docker-tag:
image: plugins/kaniko
settings:
registry: codeberg.org
dockerfile: Dockerfile
repo: codeberg.org/codeberg/pages-server
tags: [ latest, "${CI_COMMIT_TAG}" ]
username:
from_secret: bot_user
password:
from_secret: bot_token
when:
event: [ "tag" ]

View file

@ -1,123 +0,0 @@
when:
- event: [push, pull_request, tag, cron]
branch: ${CI_REPO_DEFAULT_BRANCH}
steps:
# use vendor to cache dependencies
vendor:
image: golang:1.24
commands:
- go mod vendor
build:
depends_on: vendor
image: codeberg.org/6543/docker-images/golang_just:go-1.24
commands:
- go version
- just build
when:
- event: [push, pull_request, tag]
docker-dryrun:
depends_on: vendor
image: woodpeckerci/plugin-docker-buildx:5.2.1
settings:
dockerfile: Dockerfile
platforms: linux/amd64
dry-run: true
tags: latest
when:
- event: [pull_request]
path: Dockerfile
build-tag:
depends_on: vendor
image: codeberg.org/6543/docker-images/golang_just:go-1.24
commands:
- go version
- just build-tag ${CI_COMMIT_TAG##v}
when:
- event: [tag]
test:
depends_on: build
image: codeberg.org/6543/docker-images/golang_just:go-1.24
commands:
- just test
when:
- event: [pull_request]
integration-tests:
failure: ignore
depends_on: build
image: codeberg.org/6543/docker-images/golang_just:go-1.24
commands:
- just integration
environment:
ACME_API: https://acme.mock.directory
PAGES_DOMAIN: localhost.mock.directory
RAW_DOMAIN: raw.localhost.mock.directory
PORT: 4430
when:
- event: [pull_request]
release:
depends_on: build
image: woodpeckerci/plugin-release:0.2.5
settings:
base_url: https://codeberg.org
file_exists: overwrite
files: build/codeberg-pages-server
api_key:
from_secret: bot_token
when:
- event: [tag]
docker-next:
depends_on: vendor
image: woodpeckerci/plugin-docker-buildx:5.2.1
settings:
registry: codeberg.org
dockerfile: Dockerfile
platforms: linux/amd64,arm64
repo: codeberg.org/codeberg/pages-server
tags: next
username:
from_secret: bot_user
password:
from_secret: bot_token
when:
- event: [push]
'Publish PR image':
image: woodpeckerci/plugin-docker-buildx:5.2.1
depends_on: test
settings:
registry: codeberg.org
dockerfile: Dockerfile
platforms: linux/amd64
repo: codeberg.org/codeberg/pages-server
tags: next
username:
from_secret: bot_user
password:
from_secret: bot_token
when:
evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "build_pr_image"'
event: pull_request
docker-release:
depends_on: vendor
image: woodpeckerci/plugin-docker-buildx:5.2.1
settings:
registry: codeberg.org
dockerfile: Dockerfile
platforms: linux/amd64,arm64
repo: codeberg.org/codeberg/pages-server
tags: [latest, '${CI_COMMIT_TAG}']
username:
from_secret: bot_user
password:
from_secret: bot_token
when:
- event: [tag]

View file

@ -1,30 +0,0 @@
when:
- event: pull_request
branch:
- ${CI_REPO_DEFAULT_BRANCH}
steps:
lint:
depends_on: []
image: golangci/golangci-lint:v1.64.8
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 10m --build-tags integration
editor-config:
depends_on: []
image: mstruebing/editorconfig-checker:v3.2.0
yamllint:
image: pipelinecomponents/yamllint:0.33.0
depends_on: []
commands:
- yamllint .
prettier:
image: docker.io/woodpeckerci/plugin-prettier:1.3.0
depends_on: []
settings:
version: 3.2.5

View file

@ -1,19 +0,0 @@
extends: default
rules:
comments:
require-starting-space: false
ignore-shebangs: true
min-spaces-from-content: 1
braces:
min-spaces-inside: 1
max-spaces-inside: 1
document-start:
present: false
indentation:
spaces: 2
indent-sequences: true
line-length:
max: 256
new-lines:
type: unix

View file

@ -1,36 +1,14 @@
# Set the default Go version as a build argument FROM techknowlogick/xgo as build
ARG XGO="go-1.24.x"
# Use xgo (a Go cross-compiler tool) as build image WORKDIR /workspace
FROM --platform=$BUILDPLATFORM techknowlogick/xgo:${XGO} AS build
# Set the working directory and copy the source code COPY . .
WORKDIR /go/src/codeberg.org/codeberg/pages RUN CGO_ENABLED=1 go build -tags 'sqlite sqlite_unlock_notify netgo' -ldflags '-s -w -extldflags "-static" -linkmode external' .
COPY . /go/src/codeberg.org/codeberg/pages
# Set the target architecture (can be set using --build-arg), buildx set it automatically
ARG TARGETOS TARGETARCH
# Build the binary using xgo
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=${TARGETOS} GOARCH=${TARGETARCH} CGO_ENABLED=1 \
xgo -x -v --targets=${TARGETOS}/${TARGETARCH} -tags='sqlite sqlite_unlock_notify netgo' -ldflags='-s -w -extldflags "-static" -linkmode external' -out pages .
RUN mv -vf /build/pages-* /go/src/codeberg.org/codeberg/pages/pages
# Use a scratch image as the base image for the final container,
# which will contain only the built binary and the CA certificates
FROM scratch FROM scratch
COPY --from=build /workspace/pages /pages
# Copy the built binary and the CA certificates from the build container to the final container
COPY --from=build /go/src/codeberg.org/codeberg/pages/pages /pages
COPY --from=build \ COPY --from=build \
/etc/ssl/certs/ca-certificates.crt \ /etc/ssl/certs/ca-certificates.crt \
/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# Expose ports 80 and 443 for the built binary to listen on
EXPOSE 80/tcp
EXPOSE 443/tcp
# Set the entrypoint for the container to the built binary
ENTRYPOINT ["/pages"] ENTRYPOINT ["/pages"]

View file

@ -19,16 +19,16 @@ Redirects can be created with a `_redirects` file with the following format:
from to [status] from to [status]
``` ```
- Lines starting with `#` are ignored * Lines starting with `#` are ignored
- `from` - the path to redirect from (Note: repository and branch names are removed from request URLs) * `from` - the path to redirect from (Note: repository and branch names are removed from request URLs)
- `to` - the path or URL to redirect to * `to` - the path or URL to redirect to
- `status` - status code to use when redirecting (default 301) * `status` - status code to use when redirecting (default 301)
### Status codes ### Status codes
- `200` - returns content from specified path (no external URLs) without changing the URL (rewrite) * `200` - returns content from specified path (no external URLs) without changing the URL (rewrite)
- `301` - Moved Permanently (Permanent redirect) * `301` - Moved Permanently (Permanent redirect)
- `302` - Found (Temporary redirect) * `302` - Found (Temporary redirect)
### Examples ### Examples

View file

@ -1,13 +1,18 @@
CGO_FLAGS := '-extldflags "-static" -linkmode external' CGO_FLAGS := '-extldflags "-static" -linkmode external'
TAGS := 'sqlite sqlite_unlock_notify netgo' TAGS := 'sqlite sqlite_unlock_notify netgo'
dev *FLAGS: dev:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euxo pipefail set -euxo pipefail
set -a # automatically export all variables export ACME_API=https://acme.mock.directory
source .env-dev export ACME_ACCEPT_TERMS=true
set +a export PAGES_DOMAIN=localhost.mock.directory
go run -tags '{{TAGS}}' . {{FLAGS}} export RAW_DOMAIN=raw.localhost.mock.directory
export PORT=4430
export HTTP_PORT=8880
export ENABLE_HTTP_SERVER=true
export LOG_LEVEL=trace
go run -tags '{{TAGS}}' .
build: build:
CGO_ENABLED=1 go build -tags '{{TAGS}}' -ldflags '-s -w {{CGO_FLAGS}}' -v -o build/codeberg-pages-server ./ CGO_ENABLED=1 go build -tags '{{TAGS}}' -ldflags '-s -w {{CGO_FLAGS}}' -v -o build/codeberg-pages-server ./
@ -37,10 +42,10 @@ tool-gofumpt:
fi fi
test: test:
go test -race -cover -tags '{{TAGS}}' codeberg.org/codeberg/pages/config/ codeberg.org/codeberg/pages/html/ codeberg.org/codeberg/pages/server/... go test -race -cover -tags '{{TAGS}}' codeberg.org/codeberg/pages/server/... codeberg.org/codeberg/pages/html/
test-run TEST: test-run TEST:
go test -race -tags '{{TAGS}}' -run "^{{TEST}}$" codeberg.org/codeberg/pages/config/ codeberg.org/codeberg/pages/html/ codeberg.org/codeberg/pages/server/... go test -race -tags '{{TAGS}}' -run "^{{TEST}}$" codeberg.org/codeberg/pages/server/... codeberg.org/codeberg/pages/html/
integration: integration:
go test -race -tags 'integration {{TAGS}}' codeberg.org/codeberg/pages/integration/... go test -race -tags 'integration {{TAGS}}' codeberg.org/codeberg/pages/integration/...

View file

@ -3,7 +3,7 @@
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue)](https://opensource.org/license/eupl-1-2/) [![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue)](https://opensource.org/license/eupl-1-2/)
[![status-badge](https://ci.codeberg.org/api/badges/Codeberg/pages-server/status.svg)](https://ci.codeberg.org/Codeberg/pages-server) [![status-badge](https://ci.codeberg.org/api/badges/Codeberg/pages-server/status.svg)](https://ci.codeberg.org/Codeberg/pages-server)
<a href="https://matrix.to/#/#gitea-pages-server:matrix.org" title="Join the Matrix room at https://matrix.to/#/#gitea-pages-server:matrix.org"> <a href="https://matrix.to/#/#gitea-pages-server:matrix.org" title="Join the Matrix room at https://matrix.to/#/#gitea-pages-server:matrix.org">
<img src="https://img.shields.io/matrix/gitea-pages-server:matrix.org?label=matrix"> <img src="https://img.shields.io/matrix/gitea-pages-server:matrix.org?label=matrix">
</a> </a>
Gitea lacks the ability to host static pages from Git. Gitea lacks the ability to host static pages from Git.
@ -21,17 +21,17 @@ and the [Codeberg Documentation](https://docs.codeberg.org/codeberg-pages/).
This is the new Codeberg Pages server, a solution for serving static pages from Gitea repositories. 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: 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 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. 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 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. "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 "~"): 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.` `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 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 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): record that points to your repo (just like the CNAME record):
`example.org IN ALIAS codeberg.page.` `example.org IN ALIAS codeberg.page.`
`example.org IN TXT main.pages.example.codeberg.page.` `example.org IN TXT main.pages.example.codeberg.page.`
@ -44,7 +44,6 @@ Certificates are generated, updated and cleaned up automatically via Let's Encry
## Deployment ## Deployment
**Warning: Some Caveats Apply** **Warning: Some Caveats Apply**
> Currently, the deployment requires you to have some knowledge of system administration as well as understanding and building code, > 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. > 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. > In the future, we'll try to reduce these and make hosting Codeberg Pages as easy as setting up Gitea.
@ -64,34 +63,24 @@ but forward the requests on the IP level to the Pages Server.
You can check out a proof of concept in the `examples/haproxy-sni` folder, You can check out a proof of concept in the `examples/haproxy-sni` folder,
and especially have a look at [this section of the haproxy.cfg](https://codeberg.org/Codeberg/pages-server/src/branch/main/examples/haproxy-sni/haproxy.cfg#L38). and especially have a look at [this section of the haproxy.cfg](https://codeberg.org/Codeberg/pages-server/src/branch/main/examples/haproxy-sni/haproxy.cfg#L38).
If you want to test a change, you can open a PR and ask for the label `build_pr_image` to be added.
This will trigger a build of the PR which will build a docker image to be used for testing.
### Environment Variables ### Environment Variables
- `ACME_ACCEPT_TERMS` (default: use self-signed certificate): Set this to "true" to accept the Terms of Service of your ACME provider.
- `ACME_API` (default: <https://acme-v02.api.letsencrypt.org/directory>): set this to <https://acme.mock.directory> to use invalid certificates without any verification (great for debugging). ZeroSSL might be better in the future as it doesn't have rate limits and doesn't clash with the official Codeberg certificates (which are using Let's Encrypt), but I couldn't get it to work yet.
- `ACME_EAB_KID` & `ACME_EAB_HMAC` (default: don't use EAB): EAB credentials, for example for ZeroSSL.
- `ACME_EMAIL` (default: `noreply@example.email`): Set the email sent to the ACME API server to receive, for example, renewal reminders.
- `ACME_USE_RATE_LIMITS` (default: true): Set this to false to disable rate limits, e.g. with ZeroSSL.
- `DNS_PROVIDER` (default: use self-signed certificate): Code of the ACME DNS provider for the main domain wildcard. See <https://go-acme.github.io/lego/dns/> for available values & additional environment variables.
- `ENABLE_HTTP_SERVER` (default: false): Set this to true to enable the HTTP-01 challenge and redirect all other HTTP requests to HTTPS. Currently only works with port 80.
- `GITEA_API_TOKEN` (default: empty): API token for the Gitea instance to access non-public (e.g. limited) repos.
- `GITEA_ROOT` (default: `https://codeberg.org`): root of the upstream Gitea instance.
- `HOST` & `PORT` (default: `[::]` & `443`): listen address. - `HOST` & `PORT` (default: `[::]` & `443`): listen address.
- `LOG_LEVEL` (default: warn): Set this to specify the level of logging.
- `NO_DNS_01` (default: `false`): Disable the use of ACME DNS. This means that the wildcard certificate is self-signed and all domains and subdomains will have a distinct certificate. Because this may lead to a rate limit from the ACME provider, this option is not recommended for Gitea/Forgejo instances with open registrations or a great number of users/orgs.
- `PAGES_DOMAIN` (default: `codeberg.page`): main domain for pages. - `PAGES_DOMAIN` (default: `codeberg.page`): main domain for pages.
- `RAW_DOMAIN` (default: `raw.codeberg.page`): domain for raw resources (must be subdomain of `PAGES_DOMAIN`). - `RAW_DOMAIN` (default: `raw.codeberg.page`): domain for raw resources (must be subdomain of `PAGES_DOMAIN`).
- `GITEA_ROOT` (default: `https://codeberg.org`): root of the upstream Gitea instance.
### Custom Error Page - `GITEA_API_TOKEN` (default: empty): API token for the Gitea instance to access non-public (e.g. limited) repos.
- `RAW_INFO_PAGE` (default: <https://docs.codeberg.org/pages/raw-content/>): info page for raw resources, shown if no resource is provided.
A custom error page template can be served by creating `custom/error.html`. - `ACME_API` (default: <https://acme-v02.api.letsencrypt.org/directory>): set this to <https://acme.mock.director> to use invalid certificates without any verification (great for debugging).
Data available to the template includes: ZeroSSL might be better in the future as it doesn't have rate limits and doesn't clash with the official Codeberg certificates (which are using Let's Encrypt), but I couldn't get it to work yet.
- `ACME_EMAIL` (default: `noreply@example.email`): Set the email sent to the ACME API server to receive, for example, renewal reminders.
- `{{ .StatusCode }}`: The HTTP status code (e.g. 404) - `ACME_EAB_KID` & `ACME_EAB_HMAC` (default: don't use EAB): EAB credentials, for example for ZeroSSL.
- `{{ .StatusText }}`: The textual name associated with the status code (e.g. Not Found) - `ACME_ACCEPT_TERMS` (default: use self-signed certificate): Set this to "true" to accept the Terms of Service of your ACME provider.
- `{{ .Message }}`: The reason for the error - `ACME_USE_RATE_LIMITS` (default: true): Set this to false to disable rate limits, e.g. with ZeroSSL.
- `ENABLE_HTTP_SERVER` (default: false): Set this to true to enable the HTTP-01 challenge and redirect all other HTTP requests to HTTPS. Currently only works with port 80.
- `DNS_PROVIDER` (default: use self-signed certificate): Code of the ACME DNS provider for the main domain wildcard.
See <https://go-acme.github.io/lego/dns/> for available values & additional environment variables.
- `LOG_LEVEL` (default: warn): Set this to specify the level of logging.
## Contributing to the development ## Contributing to the development
@ -127,24 +116,9 @@ Thank you very much.
Make sure you have [golang](https://go.dev) v1.21 or newer and [just](https://just.systems/man/en/) installed. Make sure you have [golang](https://go.dev) v1.21 or newer and [just](https://just.systems/man/en/) installed.
run `just dev` run `just dev`
now these pages should work: now this pages should work:
- <https://cb_pages_tests.localhost.mock.directory:4430/images/827679288a.jpg> - <https://cb_pages_tests.localhost.mock.directory:4430/images/827679288a.jpg>
- <https://momar.localhost.mock.directory:4430/ci-testing/> - <https://momar.localhost.mock.directory:4430/ci-testing/>
- <https://momar.localhost.mock.directory:4430/pag/@master/> - <https://momar.localhost.mock.directory:4430/pag/@master/>
- <https://mock-pages.codeberg-test.org:4430/README.md> - <https://mock-pages.codeberg-test.org:4430/README.md>
### Profiling
> This section is just a collection of commands for quick reference. If you want to learn more about profiling read [this](https://go.dev/doc/diagnostics) article or google `golang profiling`.
First enable profiling by supplying the cli arg `--enable-profiling` or using the environment variable `EENABLE_PROFILING`.
Get cpu and mem stats:
```bash
go tool pprof -raw -output=cpu.txt 'http://localhost:9999/debug/pprof/profile?seconds=60' &
curl -so mem.txt 'http://localhost:9999/debug/pprof/heap?seconds=60'
```
More endpoints are documented here: <https://pkg.go.dev/net/http/pprof>

View file

@ -1,39 +0,0 @@
package cli
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/server/database"
"codeberg.org/codeberg/pages/server/version"
)
func CreatePagesApp() *cli.App {
app := cli.NewApp()
app.Name = "pages-server"
app.Version = version.Version
app.Usage = "pages server"
app.Flags = ServerFlags
app.Commands = []*cli.Command{
Certs,
}
return app
}
func OpenCertDB(ctx *cli.Context) (certDB database.CertDB, closeFn func(), err error) {
certDB, err = database.NewXormDB(ctx.String("db-type"), ctx.String("db-conn"))
if err != nil {
return nil, nil, fmt.Errorf("could not connect to database: %w", err)
}
closeFn = func() {
if err := certDB.Close(); err != nil {
log.Error().Err(err)
}
}
return certDB, closeFn, nil
}

View file

@ -1,4 +1,4 @@
package cli package cmd
import ( import (
"fmt" "fmt"
@ -26,7 +26,7 @@ var Certs = &cli.Command{
} }
func listCerts(ctx *cli.Context) error { func listCerts(ctx *cli.Context) error {
certDB, closeFn, err := OpenCertDB(ctx) certDB, closeFn, err := openCertDB(ctx)
if err != nil { if err != nil {
return err return err
} }
@ -53,7 +53,7 @@ func removeCert(ctx *cli.Context) error {
domains := ctx.Args().Slice() domains := ctx.Args().Slice()
certDB, closeFn, err := OpenCertDB(ctx) certDB, closeFn, err := openCertDB(ctx)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,4 +1,4 @@
package cli package cmd
import ( import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -22,44 +22,33 @@ var (
ServerFlags = append(CertStorageFlags, []cli.Flag{ ServerFlags = append(CertStorageFlags, []cli.Flag{
// ############# // #############
// ### Forge ### // ### Gitea ###
// ############# // #############
// ForgeRoot specifies the root URL of the Forge instance, without a trailing slash. // GiteaRoot specifies the root URL of the Gitea instance, without a trailing slash.
&cli.StringFlag{ &cli.StringFlag{
Name: "forge-root", Name: "gitea-root",
Aliases: []string{"gitea-root"}, Usage: "specifies the root URL of the Gitea instance, without a trailing slash.",
Usage: "specifies the root URL of the Forgejo/Gitea instance, without a trailing slash.", EnvVars: []string{"GITEA_ROOT"},
EnvVars: []string{"FORGE_ROOT", "GITEA_ROOT"}, Value: "https://codeberg.org",
}, },
// ForgeApiToken specifies an api token for the Forge instance // GiteaApiToken specifies an api token for the Gitea instance
&cli.StringFlag{ &cli.StringFlag{
Name: "forge-api-token", Name: "gitea-api-token",
Aliases: []string{"gitea-api-token"}, Usage: "specifies an api token for the Gitea instance",
Usage: "specifies an api token for the Forgejo/Gitea instance", EnvVars: []string{"GITEA_API_TOKEN"},
EnvVars: []string{"FORGE_API_TOKEN", "GITEA_API_TOKEN"}, Value: "",
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "enable-lfs-support", Name: "enable-lfs-support",
Usage: "enable lfs support, gitea must be version v1.17.0 or higher", Usage: "enable lfs support, require gitea >= v1.17.0 as backend",
EnvVars: []string{"ENABLE_LFS_SUPPORT"}, EnvVars: []string{"ENABLE_LFS_SUPPORT"},
Value: false, Value: true,
}, },
&cli.BoolFlag{ &cli.BoolFlag{
Name: "enable-symlink-support", Name: "enable-symlink-support",
Usage: "follow symlinks if enabled, gitea must be version v1.18.0 or higher", Usage: "follow symlinks if enabled, require gitea >= v1.18.0 as backend",
EnvVars: []string{"ENABLE_SYMLINK_SUPPORT"}, EnvVars: []string{"ENABLE_SYMLINK_SUPPORT"},
Value: false, Value: true,
},
&cli.StringFlag{
Name: "default-mime-type",
Usage: "specifies the default mime type for files that don't have a specific mime type.",
EnvVars: []string{"DEFAULT_MIME_TYPE"},
Value: "application/octet-stream",
},
&cli.StringSliceFlag{
Name: "forbidden-mime-types",
Usage: "specifies the forbidden mime types. Use this flag multiple times for multiple mime types.",
EnvVars: []string{"FORBIDDEN_MIME_TYPES"},
}, },
// ########################### // ###########################
@ -72,6 +61,7 @@ var (
Name: "pages-domain", Name: "pages-domain",
Usage: "specifies the main domain (starting with a dot) for which subdomains shall be served as static pages", Usage: "specifies the main domain (starting with a dot) for which subdomains shall be served as static pages",
EnvVars: []string{"PAGES_DOMAIN"}, EnvVars: []string{"PAGES_DOMAIN"},
Value: "codeberg.page",
}, },
// RawDomain specifies the domain from which raw repository content shall be served in the following format: // RawDomain specifies the domain from which raw repository content shall be served in the following format:
// https://{RawDomain}/{owner}/{repo}[/{branch|tag|commit}/{version}]/{filepath...} // https://{RawDomain}/{owner}/{repo}[/{branch|tag|commit}/{version}]/{filepath...}
@ -80,6 +70,7 @@ var (
Name: "raw-domain", Name: "raw-domain",
Usage: "specifies the domain from which raw repository content shall be served, not set disable raw content hosting", Usage: "specifies the domain from which raw repository content shall be served, not set disable raw content hosting",
EnvVars: []string{"RAW_DOMAIN"}, EnvVars: []string{"RAW_DOMAIN"},
Value: "raw.codeberg.page",
}, },
// ######################### // #########################
@ -107,57 +98,19 @@ var (
Name: "enable-http-server", Name: "enable-http-server",
Usage: "start a http server to redirect to https and respond to http acme challenges", Usage: "start a http server to redirect to https and respond to http acme challenges",
EnvVars: []string{"ENABLE_HTTP_SERVER"}, EnvVars: []string{"ENABLE_HTTP_SERVER"},
Value: false,
}, },
&cli.BoolFlag{
Name: "use-proxy-protocol",
Usage: "use the proxy protocol",
EnvVars: []string{"USE_PROXY_PROTOCOL"},
Value: false,
},
// Default branches to fetch assets from
&cli.StringSliceFlag{
Name: "pages-branch",
Usage: "define a branch to fetch assets from. Use this flag multiple times for multiple branches.",
EnvVars: []string{"PAGES_BRANCHES"},
Value: cli.NewStringSlice("pages"),
},
&cli.StringSliceFlag{
Name: "allowed-cors-domains",
Usage: "specify allowed CORS domains. Use this flag multiple times for multiple domains.",
EnvVars: []string{"ALLOWED_CORS_DOMAINS"},
},
&cli.StringSliceFlag{
Name: "blacklisted-paths",
Usage: "return an error on these url paths.Use this flag multiple times for multiple paths.",
EnvVars: []string{"BLACKLISTED_PATHS"},
},
&cli.StringFlag{ &cli.StringFlag{
Name: "log-level", Name: "log-level",
Value: "warn", Value: "warn",
Usage: "specify at which log level should be logged. Possible options: info, warn, error, fatal", Usage: "specify at which log level should be logged. Possible options: info, warn, error, fatal",
EnvVars: []string{"LOG_LEVEL"}, EnvVars: []string{"LOG_LEVEL"},
}, },
&cli.StringFlag{ // Default branches to fetch assets from
Name: "config-file", &cli.StringSliceFlag{
Usage: "specify the location of the config file", Name: "pages-branch",
Aliases: []string{"config"}, Usage: "define a branch to fetch assets from",
EnvVars: []string{"CONFIG_FILE"}, EnvVars: []string{"PAGES_BRANCHES"},
}, Value: cli.NewStringSlice("pages"),
&cli.BoolFlag{
Name: "enable-profiling",
Usage: "enables the go http profiling endpoints",
EnvVars: []string{"ENABLE_PROFILING"},
},
&cli.StringFlag{
Name: "profiling-address",
Usage: "specify ip address and port the profiling server should listen on",
EnvVars: []string{"PROFILING_ADDRESS"},
Value: "localhost:9999",
}, },
// ############################ // ############################
@ -199,11 +152,6 @@ var (
Usage: "Use DNS-Challenge for main domain. Read more at: https://go-acme.github.io/lego/dns/", Usage: "Use DNS-Challenge for main domain. Read more at: https://go-acme.github.io/lego/dns/",
EnvVars: []string{"DNS_PROVIDER"}, EnvVars: []string{"DNS_PROVIDER"},
}, },
&cli.BoolFlag{
Name: "no-dns-01",
Usage: "Always use individual certificates instead of a DNS-01 wild card certificate",
EnvVars: []string{"NO_DNS_01"},
},
&cli.StringFlag{ &cli.StringFlag{
Name: "acme-account-config", Name: "acme-account-config",
Usage: "json file of acme account", Usage: "json file of acme account",

150
cmd/main.go Normal file
View file

@ -0,0 +1,150 @@
package cmd
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/certificates"
"codeberg.org/codeberg/pages/server/gitea"
"codeberg.org/codeberg/pages/server/handler"
)
// AllowedCorsDomains lists the domains for which Cross-Origin Resource Sharing is allowed.
// TODO: make it a flag
var AllowedCorsDomains = []string{
"fonts.codeberg.org",
"design.codeberg.org",
}
// BlacklistedPaths specifies forbidden path prefixes for all Codeberg Pages.
// TODO: Make it a flag too
var BlacklistedPaths = []string{
"/.well-known/acme-challenge/",
}
// Serve sets up and starts the web server.
func Serve(ctx *cli.Context) error {
// Initialize the logger.
logLevel, err := zerolog.ParseLevel(ctx.String("log-level"))
if err != nil {
return err
}
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger().Level(logLevel)
giteaRoot := ctx.String("gitea-root")
giteaAPIToken := ctx.String("gitea-api-token")
rawDomain := ctx.String("raw-domain")
defaultBranches := ctx.StringSlice("pages-branch")
mainDomainSuffix := ctx.String("pages-domain")
listeningHost := ctx.String("host")
listeningSSLPort := ctx.Uint("port")
listeningSSLAddress := fmt.Sprintf("%s:%d", listeningHost, listeningSSLPort)
listeningHTTPAddress := fmt.Sprintf("%s:%d", listeningHost, ctx.Uint("http-port"))
enableHTTPServer := ctx.Bool("enable-http-server")
allowedCorsDomains := AllowedCorsDomains
if rawDomain != "" {
allowedCorsDomains = append(allowedCorsDomains, rawDomain)
}
// Make sure MainDomain has a trailing dot
if !strings.HasPrefix(mainDomainSuffix, ".") {
mainDomainSuffix = "." + mainDomainSuffix
}
if len(defaultBranches) == 0 {
return fmt.Errorf("no default branches set (PAGES_BRANCHES)")
}
// Init ssl cert database
certDB, closeFn, err := openCertDB(ctx)
if err != nil {
return err
}
defer closeFn()
keyCache := cache.NewKeyValueCache()
challengeCache := cache.NewKeyValueCache()
// canonicalDomainCache stores canonical domains
canonicalDomainCache := cache.NewKeyValueCache()
// dnsLookupCache stores DNS lookups for custom domains
dnsLookupCache := cache.NewKeyValueCache()
// redirectsCache stores redirects in _redirects files
redirectsCache := cache.NewKeyValueCache()
// clientResponseCache stores responses from the Gitea server
clientResponseCache := cache.NewKeyValueCache()
giteaClient, err := gitea.NewClient(giteaRoot, giteaAPIToken, clientResponseCache, ctx.Bool("enable-symlink-support"), ctx.Bool("enable-lfs-support"))
if err != nil {
return fmt.Errorf("could not create new gitea client: %v", err)
}
acmeClient, err := createAcmeClient(ctx, enableHTTPServer, challengeCache)
if err != nil {
return err
}
if err := certificates.SetupMainDomainCertificates(mainDomainSuffix, acmeClient, certDB); err != nil {
return err
}
// Create listener for SSL connections
log.Info().Msgf("Create TCP listener for SSL on %s", listeningSSLAddress)
listener, err := net.Listen("tcp", listeningSSLAddress)
if err != nil {
return fmt.Errorf("couldn't create listener: %v", err)
}
// Setup listener for SSL connections
listener = tls.NewListener(listener, certificates.TLSConfig(mainDomainSuffix,
giteaClient,
acmeClient,
defaultBranches[0],
keyCache, challengeCache, dnsLookupCache, canonicalDomainCache,
certDB))
interval := 12 * time.Hour
certMaintainCtx, cancelCertMaintain := context.WithCancel(context.Background())
defer cancelCertMaintain()
go certificates.MaintainCertDB(certMaintainCtx, interval, acmeClient, mainDomainSuffix, certDB)
if enableHTTPServer {
// Create handler for http->https redirect and http acme challenges
httpHandler := certificates.SetupHTTPACMEChallengeServer(challengeCache, listeningSSLPort)
// Create listener for http and start listening
go func() {
log.Info().Msgf("Start HTTP server listening on %s", listeningHTTPAddress)
err := http.ListenAndServe(listeningHTTPAddress, httpHandler)
if err != nil {
log.Panic().Err(err).Msg("Couldn't start HTTP fastServer")
}
}()
}
// Create ssl handler based on settings
sslHandler := handler.Handler(mainDomainSuffix, rawDomain,
giteaClient,
BlacklistedPaths, allowedCorsDomains,
defaultBranches,
dnsLookupCache, canonicalDomainCache, redirectsCache)
// Start the ssl listener
log.Info().Msgf("Start SSL server using TCP listener on %s", listener.Addr())
if err := http.Serve(listener, sslHandler); err != nil {
log.Panic().Err(err).Msg("Couldn't start fastServer")
}
return nil
}

64
cmd/setup.go Normal file
View file

@ -0,0 +1,64 @@
package cmd
import (
"errors"
"fmt"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/certificates"
"codeberg.org/codeberg/pages/server/database"
)
var ErrAcmeMissConfig = errors.New("ACME client has wrong config")
func openCertDB(ctx *cli.Context) (certDB database.CertDB, closeFn func(), err error) {
certDB, err = database.NewXormDB(ctx.String("db-type"), ctx.String("db-conn"))
if err != nil {
return nil, nil, fmt.Errorf("could not connect to database: %w", err)
}
closeFn = func() {
if err := certDB.Close(); err != nil {
log.Error().Err(err)
}
}
return certDB, closeFn, nil
}
func createAcmeClient(ctx *cli.Context, enableHTTPServer bool, challengeCache cache.SetGetKey) (*certificates.AcmeClient, error) {
acmeAPI := ctx.String("acme-api-endpoint")
acmeMail := ctx.String("acme-email")
acmeEabHmac := ctx.String("acme-eab-hmac")
acmeEabKID := ctx.String("acme-eab-kid")
acmeAcceptTerms := ctx.Bool("acme-accept-terms")
dnsProvider := ctx.String("dns-provider")
acmeUseRateLimits := ctx.Bool("acme-use-rate-limits")
acmeAccountConf := ctx.String("acme-account-config")
// check config
if (!acmeAcceptTerms || dnsProvider == "") && acmeAPI != "https://acme.mock.directory" {
return nil, fmt.Errorf("%w: you must set $ACME_ACCEPT_TERMS and $DNS_PROVIDER, unless $ACME_API is set to https://acme.mock.directory", ErrAcmeMissConfig)
}
if acmeEabHmac != "" && acmeEabKID == "" {
return nil, fmt.Errorf("%w: ACME_EAB_HMAC also needs ACME_EAB_KID to be set", ErrAcmeMissConfig)
} else if acmeEabHmac == "" && acmeEabKID != "" {
return nil, fmt.Errorf("%w: ACME_EAB_KID also needs ACME_EAB_HMAC to be set", ErrAcmeMissConfig)
}
return certificates.NewAcmeClient(
acmeAccountConf,
acmeAPI,
acmeMail,
acmeEabHmac,
acmeEabKID,
dnsProvider,
acmeAcceptTerms,
enableHTTPServer,
acmeUseRateLimits,
challengeCache,
)
}

View file

@ -1,33 +0,0 @@
logLevel = 'trace'
[server]
host = '127.0.0.1'
port = 443
httpPort = 80
httpServerEnabled = true
mainDomain = 'codeberg.page'
rawDomain = 'raw.codeberg.page'
allowedCorsDomains = ['fonts.codeberg.org', 'design.codeberg.org']
blacklistedPaths = ['do/not/use']
[forge]
root = 'https://codeberg.org'
token = 'XXXXXXXX'
lfsEnabled = true
followSymlinks = true
defaultMimeType = "application/wasm"
forbiddenMimeTypes = ["text/html"]
[database]
type = 'sqlite'
conn = 'certs.sqlite'
[ACME]
email = 'a@b.c'
apiEndpoint = 'https://example.com'
acceptTerms = false
useRateLimits = true
eab_hmac = 'asdf'
eab_kid = 'qwer'
dnsProvider = 'cloudflare.com'
accountConfigFile = 'nope'

View file

@ -1,48 +0,0 @@
package config
type Config struct {
LogLevel string `default:"warn"`
Server ServerConfig
Forge ForgeConfig
Database DatabaseConfig
ACME ACMEConfig
}
type ServerConfig struct {
Host string `default:"[::]"`
Port uint16 `default:"443"`
HttpPort uint16 `default:"80"`
HttpServerEnabled bool `default:"true"`
UseProxyProtocol bool `default:"false"`
MainDomain string
RawDomain string
PagesBranches []string
AllowedCorsDomains []string
BlacklistedPaths []string
}
type ForgeConfig struct {
Root string
Token string
LFSEnabled bool `default:"false"`
FollowSymlinks bool `default:"false"`
DefaultMimeType string `default:"application/octet-stream"`
ForbiddenMimeTypes []string
}
type DatabaseConfig struct {
Type string `default:"sqlite3"`
Conn string `default:"certs.sqlite"`
}
type ACMEConfig struct {
Email string
APIEndpoint string `default:"https://acme-v02.api.letsencrypt.org/directory"`
AcceptTerms bool `default:"false"`
UseRateLimits bool `default:"true"`
EAB_HMAC string
EAB_KID string
DNSProvider string
NoDNS01 bool `default:"false"`
AccountConfigFile string `default:"acme-account.json"`
}

View file

@ -1,154 +0,0 @@
package config
import (
"os"
"path"
"github.com/creasty/defaults"
"github.com/pelletier/go-toml/v2"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
)
var ALWAYS_BLACKLISTED_PATHS = []string{
"/.well-known/acme-challenge/",
}
func NewDefaultConfig() Config {
config := Config{}
if err := defaults.Set(&config); err != nil {
panic(err)
}
// defaults does not support setting arrays from strings
config.Server.PagesBranches = []string{"main", "master", "pages"}
return config
}
func ReadConfig(ctx *cli.Context) (*Config, error) {
config := NewDefaultConfig()
// if config is not given as argument return empty config
if !ctx.IsSet("config-file") {
return &config, nil
}
configFile := path.Clean(ctx.String("config-file"))
log.Debug().Str("config-file", configFile).Msg("reading config file")
content, err := os.ReadFile(configFile)
if err != nil {
return nil, err
}
err = toml.Unmarshal(content, &config)
return &config, err
}
func MergeConfig(ctx *cli.Context, config *Config) {
if ctx.IsSet("log-level") {
config.LogLevel = ctx.String("log-level")
}
mergeServerConfig(ctx, &config.Server)
mergeForgeConfig(ctx, &config.Forge)
mergeDatabaseConfig(ctx, &config.Database)
mergeACMEConfig(ctx, &config.ACME)
}
func mergeServerConfig(ctx *cli.Context, config *ServerConfig) {
if ctx.IsSet("host") {
config.Host = ctx.String("host")
}
if ctx.IsSet("port") {
config.Port = uint16(ctx.Uint("port"))
}
if ctx.IsSet("http-port") {
config.HttpPort = uint16(ctx.Uint("http-port"))
}
if ctx.IsSet("enable-http-server") {
config.HttpServerEnabled = ctx.Bool("enable-http-server")
}
if ctx.IsSet("use-proxy-protocol") {
config.UseProxyProtocol = ctx.Bool("use-proxy-protocol")
}
if ctx.IsSet("pages-domain") {
config.MainDomain = ctx.String("pages-domain")
}
if ctx.IsSet("raw-domain") {
config.RawDomain = ctx.String("raw-domain")
}
if ctx.IsSet("pages-branch") {
config.PagesBranches = ctx.StringSlice("pages-branch")
}
if ctx.IsSet("allowed-cors-domains") {
config.AllowedCorsDomains = ctx.StringSlice("allowed-cors-domains")
}
if ctx.IsSet("blacklisted-paths") {
config.BlacklistedPaths = ctx.StringSlice("blacklisted-paths")
}
// add the paths that should always be blacklisted
config.BlacklistedPaths = append(config.BlacklistedPaths, ALWAYS_BLACKLISTED_PATHS...)
}
func mergeForgeConfig(ctx *cli.Context, config *ForgeConfig) {
if ctx.IsSet("forge-root") {
config.Root = ctx.String("forge-root")
}
if ctx.IsSet("forge-api-token") {
config.Token = ctx.String("forge-api-token")
}
if ctx.IsSet("enable-lfs-support") {
config.LFSEnabled = ctx.Bool("enable-lfs-support")
}
if ctx.IsSet("enable-symlink-support") {
config.FollowSymlinks = ctx.Bool("enable-symlink-support")
}
if ctx.IsSet("default-mime-type") {
config.DefaultMimeType = ctx.String("default-mime-type")
}
if ctx.IsSet("forbidden-mime-types") {
config.ForbiddenMimeTypes = ctx.StringSlice("forbidden-mime-types")
}
}
func mergeDatabaseConfig(ctx *cli.Context, config *DatabaseConfig) {
if ctx.IsSet("db-type") {
config.Type = ctx.String("db-type")
}
if ctx.IsSet("db-conn") {
config.Conn = ctx.String("db-conn")
}
}
func mergeACMEConfig(ctx *cli.Context, config *ACMEConfig) {
if ctx.IsSet("acme-email") {
config.Email = ctx.String("acme-email")
}
if ctx.IsSet("acme-api-endpoint") {
config.APIEndpoint = ctx.String("acme-api-endpoint")
}
if ctx.IsSet("acme-accept-terms") {
config.AcceptTerms = ctx.Bool("acme-accept-terms")
}
if ctx.IsSet("acme-use-rate-limits") {
config.UseRateLimits = ctx.Bool("acme-use-rate-limits")
}
if ctx.IsSet("acme-eab-hmac") {
config.EAB_HMAC = ctx.String("acme-eab-hmac")
}
if ctx.IsSet("acme-eab-kid") {
config.EAB_KID = ctx.String("acme-eab-kid")
}
if ctx.IsSet("dns-provider") {
config.DNSProvider = ctx.String("dns-provider")
}
if ctx.IsSet("no-dns-01") {
config.NoDNS01 = ctx.Bool("no-dns-01")
}
if ctx.IsSet("acme-account-config") {
config.AccountConfigFile = ctx.String("acme-account-config")
}
}

View file

@ -1,630 +0,0 @@
package config
import (
"context"
"os"
"testing"
"github.com/pelletier/go-toml/v2"
"github.com/stretchr/testify/assert"
"github.com/urfave/cli/v2"
cmd "codeberg.org/codeberg/pages/cli"
)
func runApp(t *testing.T, fn func(*cli.Context) error, args []string) {
app := cmd.CreatePagesApp()
app.Action = fn
appCtx, appCancel := context.WithCancel(context.Background())
defer appCancel()
// os.Args always contains the binary name
args = append([]string{"testing"}, args...)
err := app.RunContext(appCtx, args)
assert.NoError(t, err)
}
// fixArrayFromCtx fixes the number of "changed" strings in a string slice according to the number of values in the context.
// This is a workaround because the cli library has a bug where the number of values in the context gets bigger the more tests are run.
func fixArrayFromCtx(ctx *cli.Context, key string, expected []string) []string {
if ctx.IsSet(key) {
ctxSlice := ctx.StringSlice(key)
if len(ctxSlice) > 1 {
for i := 1; i < len(ctxSlice); i++ {
expected = append([]string{"changed"}, expected...)
}
}
}
return expected
}
func readTestConfig() (*Config, error) {
content, err := os.ReadFile("assets/test_config.toml")
if err != nil {
return nil, err
}
expectedConfig := NewDefaultConfig()
err = toml.Unmarshal(content, &expectedConfig)
if err != nil {
return nil, err
}
return &expectedConfig, nil
}
func TestReadConfigShouldReturnEmptyConfigWhenConfigArgEmpty(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg, err := ReadConfig(ctx)
expected := NewDefaultConfig()
assert.Equal(t, &expected, cfg)
return err
},
[]string{},
)
}
func TestReadConfigShouldReturnConfigFromFileWhenConfigArgPresent(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg, err := ReadConfig(ctx)
if err != nil {
return err
}
expectedConfig, err := readTestConfig()
if err != nil {
return err
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{"--config-file", "assets/test_config.toml"},
)
}
func TestValuesReadFromConfigFileShouldBeOverwrittenByArgs(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg, err := ReadConfig(ctx)
if err != nil {
return err
}
MergeConfig(ctx, cfg)
expectedConfig, err := readTestConfig()
if err != nil {
return err
}
expectedConfig.LogLevel = "debug"
expectedConfig.Forge.Root = "not-codeberg.org"
expectedConfig.ACME.AcceptTerms = true
expectedConfig.Server.Host = "172.17.0.2"
expectedConfig.Server.BlacklistedPaths = append(expectedConfig.Server.BlacklistedPaths, ALWAYS_BLACKLISTED_PATHS...)
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--config-file", "assets/test_config.toml",
"--log-level", "debug",
"--forge-root", "not-codeberg.org",
"--acme-accept-terms",
"--host", "172.17.0.2",
},
)
}
func TestMergeConfigShouldReplaceAllExistingValuesGivenAllArgsExist(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &Config{
LogLevel: "original",
Server: ServerConfig{
Host: "original",
Port: 8080,
HttpPort: 80,
HttpServerEnabled: false,
MainDomain: "original",
RawDomain: "original",
PagesBranches: []string{"original"},
AllowedCorsDomains: []string{"original"},
BlacklistedPaths: []string{"original"},
},
Forge: ForgeConfig{
Root: "original",
Token: "original",
LFSEnabled: false,
FollowSymlinks: false,
DefaultMimeType: "original",
ForbiddenMimeTypes: []string{"original"},
},
Database: DatabaseConfig{
Type: "original",
Conn: "original",
},
ACME: ACMEConfig{
Email: "original",
APIEndpoint: "original",
AcceptTerms: false,
UseRateLimits: false,
EAB_HMAC: "original",
EAB_KID: "original",
DNSProvider: "original",
NoDNS01: false,
AccountConfigFile: "original",
},
}
MergeConfig(ctx, cfg)
expectedConfig := &Config{
LogLevel: "changed",
Server: ServerConfig{
Host: "changed",
Port: 8443,
HttpPort: 443,
HttpServerEnabled: true,
MainDomain: "changed",
RawDomain: "changed",
PagesBranches: []string{"changed"},
AllowedCorsDomains: []string{"changed"},
BlacklistedPaths: append([]string{"changed"}, ALWAYS_BLACKLISTED_PATHS...),
},
Forge: ForgeConfig{
Root: "changed",
Token: "changed",
LFSEnabled: true,
FollowSymlinks: true,
DefaultMimeType: "changed",
ForbiddenMimeTypes: []string{"changed"},
},
Database: DatabaseConfig{
Type: "changed",
Conn: "changed",
},
ACME: ACMEConfig{
Email: "changed",
APIEndpoint: "changed",
AcceptTerms: true,
UseRateLimits: true,
EAB_HMAC: "changed",
EAB_KID: "changed",
DNSProvider: "changed",
NoDNS01: true,
AccountConfigFile: "changed",
},
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--log-level", "changed",
// Server
"--pages-domain", "changed",
"--raw-domain", "changed",
"--allowed-cors-domains", "changed",
"--blacklisted-paths", "changed",
"--pages-branch", "changed",
"--host", "changed",
"--port", "8443",
"--http-port", "443",
"--enable-http-server",
// Forge
"--forge-root", "changed",
"--forge-api-token", "changed",
"--enable-lfs-support",
"--enable-symlink-support",
"--default-mime-type", "changed",
"--forbidden-mime-types", "changed",
// Database
"--db-type", "changed",
"--db-conn", "changed",
// ACME
"--acme-email", "changed",
"--acme-api-endpoint", "changed",
"--acme-accept-terms",
"--acme-use-rate-limits",
"--acme-eab-hmac", "changed",
"--acme-eab-kid", "changed",
"--dns-provider", "changed",
"--no-dns-01",
"--acme-account-config", "changed",
},
)
}
func TestMergeServerConfigShouldAddDefaultBlacklistedPathsToBlacklistedPaths(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &ServerConfig{}
mergeServerConfig(ctx, cfg)
expected := ALWAYS_BLACKLISTED_PATHS
assert.Equal(t, expected, cfg.BlacklistedPaths)
return nil
},
[]string{},
)
}
func TestMergeServerConfigShouldReplaceAllExistingValuesGivenAllArgsExist(t *testing.T) {
for range []uint8{0, 1} {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &ServerConfig{
Host: "original",
Port: 8080,
HttpPort: 80,
HttpServerEnabled: false,
MainDomain: "original",
RawDomain: "original",
AllowedCorsDomains: []string{"original"},
BlacklistedPaths: []string{"original"},
}
mergeServerConfig(ctx, cfg)
expectedConfig := &ServerConfig{
Host: "changed",
Port: 8443,
HttpPort: 443,
HttpServerEnabled: true,
MainDomain: "changed",
RawDomain: "changed",
AllowedCorsDomains: fixArrayFromCtx(ctx, "allowed-cors-domains", []string{"changed"}),
BlacklistedPaths: fixArrayFromCtx(ctx, "blacklisted-paths", append([]string{"changed"}, ALWAYS_BLACKLISTED_PATHS...)),
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--pages-domain", "changed",
"--raw-domain", "changed",
"--allowed-cors-domains", "changed",
"--blacklisted-paths", "changed",
"--host", "changed",
"--port", "8443",
"--http-port", "443",
"--enable-http-server",
},
)
}
}
func TestMergeServerConfigShouldReplaceOnlyOneValueExistingValueGivenOnlyOneArgExists(t *testing.T) {
type testValuePair struct {
args []string
callback func(*ServerConfig)
}
testValuePairs := []testValuePair{
{args: []string{"--host", "changed"}, callback: func(sc *ServerConfig) { sc.Host = "changed" }},
{args: []string{"--port", "8443"}, callback: func(sc *ServerConfig) { sc.Port = 8443 }},
{args: []string{"--http-port", "443"}, callback: func(sc *ServerConfig) { sc.HttpPort = 443 }},
{args: []string{"--enable-http-server"}, callback: func(sc *ServerConfig) { sc.HttpServerEnabled = true }},
{args: []string{"--pages-domain", "changed"}, callback: func(sc *ServerConfig) { sc.MainDomain = "changed" }},
{args: []string{"--raw-domain", "changed"}, callback: func(sc *ServerConfig) { sc.RawDomain = "changed" }},
{args: []string{"--pages-branch", "changed"}, callback: func(sc *ServerConfig) { sc.PagesBranches = []string{"changed"} }},
{args: []string{"--allowed-cors-domains", "changed"}, callback: func(sc *ServerConfig) { sc.AllowedCorsDomains = []string{"changed"} }},
{args: []string{"--blacklisted-paths", "changed"}, callback: func(sc *ServerConfig) { sc.BlacklistedPaths = []string{"changed"} }},
}
for _, pair := range testValuePairs {
runApp(
t,
func(ctx *cli.Context) error {
cfg := ServerConfig{
Host: "original",
Port: 8080,
HttpPort: 80,
HttpServerEnabled: false,
MainDomain: "original",
RawDomain: "original",
PagesBranches: []string{"original"},
AllowedCorsDomains: []string{"original"},
BlacklistedPaths: []string{"original"},
}
expectedConfig := cfg
pair.callback(&expectedConfig)
expectedConfig.BlacklistedPaths = append(expectedConfig.BlacklistedPaths, ALWAYS_BLACKLISTED_PATHS...)
expectedConfig.PagesBranches = fixArrayFromCtx(ctx, "pages-branch", expectedConfig.PagesBranches)
expectedConfig.AllowedCorsDomains = fixArrayFromCtx(ctx, "allowed-cors-domains", expectedConfig.AllowedCorsDomains)
expectedConfig.BlacklistedPaths = fixArrayFromCtx(ctx, "blacklisted-paths", expectedConfig.BlacklistedPaths)
mergeServerConfig(ctx, &cfg)
assert.Equal(t, expectedConfig, cfg)
return nil
},
pair.args,
)
}
}
func TestMergeForgeConfigShouldReplaceAllExistingValuesGivenAllArgsExist(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &ForgeConfig{
Root: "original",
Token: "original",
LFSEnabled: false,
FollowSymlinks: false,
DefaultMimeType: "original",
ForbiddenMimeTypes: []string{"original"},
}
mergeForgeConfig(ctx, cfg)
expectedConfig := &ForgeConfig{
Root: "changed",
Token: "changed",
LFSEnabled: true,
FollowSymlinks: true,
DefaultMimeType: "changed",
ForbiddenMimeTypes: fixArrayFromCtx(ctx, "forbidden-mime-types", []string{"changed"}),
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--forge-root", "changed",
"--forge-api-token", "changed",
"--enable-lfs-support",
"--enable-symlink-support",
"--default-mime-type", "changed",
"--forbidden-mime-types", "changed",
},
)
}
func TestMergeForgeConfigShouldReplaceOnlyOneValueExistingValueGivenOnlyOneArgExists(t *testing.T) {
type testValuePair struct {
args []string
callback func(*ForgeConfig)
}
testValuePairs := []testValuePair{
{args: []string{"--forge-root", "changed"}, callback: func(gc *ForgeConfig) { gc.Root = "changed" }},
{args: []string{"--forge-api-token", "changed"}, callback: func(gc *ForgeConfig) { gc.Token = "changed" }},
{args: []string{"--enable-lfs-support"}, callback: func(gc *ForgeConfig) { gc.LFSEnabled = true }},
{args: []string{"--enable-symlink-support"}, callback: func(gc *ForgeConfig) { gc.FollowSymlinks = true }},
{args: []string{"--default-mime-type", "changed"}, callback: func(gc *ForgeConfig) { gc.DefaultMimeType = "changed" }},
{args: []string{"--forbidden-mime-types", "changed"}, callback: func(gc *ForgeConfig) { gc.ForbiddenMimeTypes = []string{"changed"} }},
}
for _, pair := range testValuePairs {
runApp(
t,
func(ctx *cli.Context) error {
cfg := ForgeConfig{
Root: "original",
Token: "original",
LFSEnabled: false,
FollowSymlinks: false,
DefaultMimeType: "original",
ForbiddenMimeTypes: []string{"original"},
}
expectedConfig := cfg
pair.callback(&expectedConfig)
mergeForgeConfig(ctx, &cfg)
expectedConfig.ForbiddenMimeTypes = fixArrayFromCtx(ctx, "forbidden-mime-types", expectedConfig.ForbiddenMimeTypes)
assert.Equal(t, expectedConfig, cfg)
return nil
},
pair.args,
)
}
}
func TestMergeForgeConfigShouldReplaceValuesGivenGiteaOptionsExist(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &ForgeConfig{
Root: "original",
Token: "original",
}
mergeForgeConfig(ctx, cfg)
expectedConfig := &ForgeConfig{
Root: "changed",
Token: "changed",
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--gitea-root", "changed",
"--gitea-api-token", "changed",
},
)
}
func TestMergeDatabaseConfigShouldReplaceAllExistingValuesGivenAllArgsExist(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &DatabaseConfig{
Type: "original",
Conn: "original",
}
mergeDatabaseConfig(ctx, cfg)
expectedConfig := &DatabaseConfig{
Type: "changed",
Conn: "changed",
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--db-type", "changed",
"--db-conn", "changed",
},
)
}
func TestMergeDatabaseConfigShouldReplaceOnlyOneValueExistingValueGivenOnlyOneArgExists(t *testing.T) {
type testValuePair struct {
args []string
callback func(*DatabaseConfig)
}
testValuePairs := []testValuePair{
{args: []string{"--db-type", "changed"}, callback: func(gc *DatabaseConfig) { gc.Type = "changed" }},
{args: []string{"--db-conn", "changed"}, callback: func(gc *DatabaseConfig) { gc.Conn = "changed" }},
}
for _, pair := range testValuePairs {
runApp(
t,
func(ctx *cli.Context) error {
cfg := DatabaseConfig{
Type: "original",
Conn: "original",
}
expectedConfig := cfg
pair.callback(&expectedConfig)
mergeDatabaseConfig(ctx, &cfg)
assert.Equal(t, expectedConfig, cfg)
return nil
},
pair.args,
)
}
}
func TestMergeACMEConfigShouldReplaceAllExistingValuesGivenAllArgsExist(t *testing.T) {
runApp(
t,
func(ctx *cli.Context) error {
cfg := &ACMEConfig{
Email: "original",
APIEndpoint: "original",
AcceptTerms: false,
UseRateLimits: false,
EAB_HMAC: "original",
EAB_KID: "original",
DNSProvider: "original",
NoDNS01: false,
AccountConfigFile: "original",
}
mergeACMEConfig(ctx, cfg)
expectedConfig := &ACMEConfig{
Email: "changed",
APIEndpoint: "changed",
AcceptTerms: true,
UseRateLimits: true,
EAB_HMAC: "changed",
EAB_KID: "changed",
DNSProvider: "changed",
NoDNS01: true,
AccountConfigFile: "changed",
}
assert.Equal(t, expectedConfig, cfg)
return nil
},
[]string{
"--acme-email", "changed",
"--acme-api-endpoint", "changed",
"--acme-accept-terms",
"--acme-use-rate-limits",
"--acme-eab-hmac", "changed",
"--acme-eab-kid", "changed",
"--dns-provider", "changed",
"--no-dns-01",
"--acme-account-config", "changed",
},
)
}
func TestMergeACMEConfigShouldReplaceOnlyOneValueExistingValueGivenOnlyOneArgExists(t *testing.T) {
type testValuePair struct {
args []string
callback func(*ACMEConfig)
}
testValuePairs := []testValuePair{
{args: []string{"--acme-email", "changed"}, callback: func(gc *ACMEConfig) { gc.Email = "changed" }},
{args: []string{"--acme-api-endpoint", "changed"}, callback: func(gc *ACMEConfig) { gc.APIEndpoint = "changed" }},
{args: []string{"--acme-accept-terms"}, callback: func(gc *ACMEConfig) { gc.AcceptTerms = true }},
{args: []string{"--acme-use-rate-limits"}, callback: func(gc *ACMEConfig) { gc.UseRateLimits = true }},
{args: []string{"--acme-eab-hmac", "changed"}, callback: func(gc *ACMEConfig) { gc.EAB_HMAC = "changed" }},
{args: []string{"--acme-eab-kid", "changed"}, callback: func(gc *ACMEConfig) { gc.EAB_KID = "changed" }},
{args: []string{"--dns-provider", "changed"}, callback: func(gc *ACMEConfig) { gc.DNSProvider = "changed" }},
{args: []string{"--no-dns-01"}, callback: func(gc *ACMEConfig) { gc.NoDNS01 = true }},
{args: []string{"--acme-account-config", "changed"}, callback: func(gc *ACMEConfig) { gc.AccountConfigFile = "changed" }},
}
for _, pair := range testValuePairs {
runApp(
t,
func(ctx *cli.Context) error {
cfg := ACMEConfig{
Email: "original",
APIEndpoint: "original",
AcceptTerms: false,
UseRateLimits: false,
EAB_HMAC: "original",
EAB_KID: "original",
DNSProvider: "original",
AccountConfigFile: "original",
}
expectedConfig := cfg
pair.callback(&expectedConfig)
mergeACMEConfig(ctx, &cfg)
assert.Equal(t, expectedConfig, cfg)
return nil
},
pair.args,
)
}
}

View file

@ -1,32 +0,0 @@
logLevel = 'debug'
[server]
host = '[::]'
port = 443
httpPort = 80
httpServerEnabled = true
mainDomain = 'codeberg.page'
rawDomain = 'raw.codeberg.page'
pagesBranches = ["pages"]
allowedCorsDomains = []
blacklistedPaths = []
[forge]
root = 'https://codeberg.org'
token = 'ASDF1234'
lfsEnabled = true
followSymlinks = true
[database]
type = 'sqlite'
conn = 'certs.sqlite'
[ACME]
email = 'noreply@example.email'
apiEndpoint = 'https://acme-v02.api.letsencrypt.org/directory'
acceptTerms = false
useRateLimits = false
eab_hmac = ''
eab_kid = ''
dnsProvider = ''
accountConfigFile = 'acme-account.json'

View file

@ -1,9 +1,8 @@
# HAProxy with SNI & Host-based rules # HAProxy with SNI & Host-based rules
This is a proof of concept, enabling HAProxy to use _either_ SNI to redirect to backends with their own HTTPS certificates (which are then fully exposed to the client; HAProxy only proxies on a TCP level in that case), _as well as_ to terminate HTTPS and use the Host header to redirect to backends that use HTTP (or a new HTTPS connection). This is a proof of concept, enabling HAProxy to use *either* SNI to redirect to backends with their own HTTPS certificates (which are then fully exposed to the client; HAProxy only proxies on a TCP level in that case), *as well as* to terminate HTTPS and use the Host header to redirect to backends that use HTTP (or a new HTTPS connection).
## How it works ## How it works
1. The `http_redirect_frontend` is only there to listen on port 80 and redirect every request to HTTPS. 1. The `http_redirect_frontend` is only there to listen on port 80 and redirect every request to HTTPS.
2. The `https_sni_frontend` listens on port 443 and chooses a backend based on the SNI hostname of the TLS connection. 2. The `https_sni_frontend` listens on port 443 and chooses a backend based on the SNI hostname of the TLS connection.
3. The `https_termination_backend` passes all requests to a unix socket (using the plain TCP data). 3. The `https_termination_backend` passes all requests to a unix socket (using the plain TCP data).
@ -12,7 +11,6 @@ This is a proof of concept, enabling HAProxy to use _either_ SNI to redirect to
In the example (see [haproxy.cfg](haproxy.cfg)), the `pages_backend` is listening via HTTPS and is providing its own HTTPS certificates, while the `gitea_backend` only provides HTTP. In the example (see [haproxy.cfg](haproxy.cfg)), the `pages_backend` is listening via HTTPS and is providing its own HTTPS certificates, while the `gitea_backend` only provides HTTP.
## How to test ## How to test
```bash ```bash
docker-compose up & docker-compose up &
./test.sh ./test.sh

View file

@ -1,8 +1,8 @@
version: '3' version: "3"
services: services:
haproxy: haproxy:
image: haproxy image: haproxy
ports: ['443:443'] ports: ["443:443"]
volumes: volumes:
- ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
- ./dhparam.pem:/etc/ssl/dhparam.pem:ro - ./dhparam.pem:/etc/ssl/dhparam.pem:ro
@ -19,3 +19,4 @@ services:
volumes: volumes:
- ./pages-www:/srv:ro - ./pages-www:/srv:ro
- ./pages.Caddyfile:/etc/caddy/Caddyfile:ro - ./pages.Caddyfile:/etc/caddy/Caddyfile:ro

71
flake.lock generated
View file

@ -1,71 +0,0 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"id": "flake-utils",
"type": "indirect"
}
},
"nixpkgs": {
"locked": {
"lastModified": 0,
"narHash": "sha256-WFZDy4bG2RkkCQloIEG8BXEvzyKklFVJbAismOJsIp4=",
"path": "/nix/store/c77dsgfxjywplw8bk8s8jlkdsr7a1bi9-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"systems": "systems_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"id": "systems",
"type": "indirect"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,27 +0,0 @@
{
outputs = {
self,
nixpkgs,
flake-utils,
systems,
}:
flake-utils.lib.eachSystem (import systems)
(system: let
pkgs = import nixpkgs {
inherit system;
};
in {
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
glibc.static
go
gofumpt
golangci-lint
gopls
gotools
go-tools
sqlite-interactive
];
};
});
}

291
go.mod
View file

@ -1,237 +1,140 @@
module codeberg.org/codeberg/pages module codeberg.org/codeberg/pages
go 1.24.0 go 1.21
toolchain go1.21.4
require ( require (
code.gitea.io/sdk/gitea v0.20.0 code.gitea.io/sdk/gitea v0.16.1-0.20231115014337-e23e8aa3004f
github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a
github.com/creasty/defaults v1.8.0 github.com/go-acme/lego/v4 v4.5.3
github.com/go-acme/lego/v4 v4.21.0 github.com/go-sql-driver/mysql v1.6.0
github.com/go-sql-driver/mysql v1.8.1 github.com/joho/godotenv v1.4.0
github.com/hashicorp/go-uuid v1.0.3 github.com/lib/pq v1.10.7
github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/mattn/go-sqlite3 v1.14.16
github.com/joho/godotenv v1.5.1 github.com/microcosm-cc/bluemonday v1.0.26
github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.24
github.com/microcosm-cc/bluemonday v1.0.27
github.com/pelletier/go-toml/v2 v2.2.3
github.com/pires/go-proxyproto v0.8.0
github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad github.com/reugn/equalizer v0.0.0-20210216135016-a959c509d7ad
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.27.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.27.5 github.com/urfave/cli/v2 v2.3.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb
xorm.io/xorm v1.3.9 xorm.io/xorm v1.3.2
) )
require ( require (
cloud.google.com/go/auth v0.14.0 // indirect cloud.google.com/go v0.54.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect github.com/Azure/azure-sdk-for-go v32.4.0+incompatible // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.29 // indirect github.com/Azure/go-autorest/autorest v0.11.19 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // 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/date v0.3.0 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.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/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.3.2 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect github.com/akamai/AkamaiOPEN-edgegrid-golang v1.1.1 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.83 // indirect github.com/aliyun/alibaba-cloud-sdk-go v1.61.1183 // indirect
github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect github.com/aws/aws-sdk-go v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.53 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.10 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.48.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.8 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/boombuler/barcode v1.0.2 // indirect github.com/cenkalti/backoff/v4 v4.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cloudflare/cloudflare-go v0.20.0 // indirect
github.com/civo/civogo v0.3.92 // indirect
github.com/cloudflare/cloudflare-go v0.114.0 // indirect
github.com/cpu/goacmedns v0.1.1 // indirect github.com/cpu/goacmedns v0.1.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/deepmap/oapi-codegen v1.6.1 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect github.com/dnsimple/dnsimple-go v0.70.1 // indirect
github.com/exoscale/egoscale/v3 v3.1.8 // indirect github.com/exoscale/egoscale v0.67.0 // indirect
github.com/fatih/structs v1.1.0 // indirect github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-errors/errors v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/goccy/go-json v0.8.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/gofrs/uuid v4.0.0+incompatible // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/go-playground/validator/v10 v10.24.0 // indirect
github.com/go-resty/resty/v2 v2.16.3 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/gax-go/v2 v2.0.5 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/gophercloud/gophercloud v0.16.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/gorilla/css v1.0.0 // indirect
github.com/gophercloud/gophercloud v1.14.1 // indirect github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect github.com/hashicorp/go-retryablehttp v0.7.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.132 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // 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/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b // indirect
github.com/kylelemons/godebug v1.1.0 // 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/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect github.com/labbsr0x/goh v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/linode/linodego v0.31.1 // indirect
github.com/linode/linodego v1.46.0 // indirect github.com/liquidweb/go-lwApi v0.0.5 // indirect
github.com/liquidweb/liquidweb-cli v0.7.0 // indirect github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // indirect github.com/liquidweb/liquidweb-go v1.6.3 // indirect
github.com/magiconair/properties v1.8.9 // indirect github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/miekg/dns v1.1.43 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect github.com/nrdcg/auroradns v1.0.1 // indirect
github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect github.com/nrdcg/desec v0.6.0 // indirect
github.com/nrdcg/desec v0.10.0 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.3.0 // indirect github.com/nrdcg/freemyip v0.2.0 // indirect
github.com/nrdcg/goinwx v0.10.0 // indirect github.com/nrdcg/goinwx v0.8.1 // indirect
github.com/nrdcg/mailinabox v0.2.0 // indirect
github.com/nrdcg/namesilo v0.2.1 // indirect github.com/nrdcg/namesilo v0.2.1 // indirect
github.com/nrdcg/nodion v0.1.0 // indirect github.com/nrdcg/porkbun v0.1.1 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
github.com/nzdjb/go-metaname v1.0.0 // indirect github.com/ovh/go-ovh v1.1.0 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/oracle/oci-go-sdk/v65 v65.81.2 // indirect
github.com/ovh/go-ovh v1.6.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/pquerna/otp v1.4.0 // indirect github.com/pquerna/otp v1.3.0 // indirect
github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sacloud/libsacloud v1.36.2 // indirect
github.com/sacloud/api-client-go v0.2.10 // indirect github.com/scaleway/scaleway-sdk-go v1.0.0-beta.7.0.20210127161313-bd30bebeac4f // indirect
github.com/sacloud/go-http v0.1.9 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sacloud/iaas-api-go v1.14.0 // indirect github.com/sirupsen/logrus v1.4.2 // indirect
github.com/sacloud/packages-go v0.0.11 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.1.7 // indirect github.com/softlayer/softlayer-go v1.0.3 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect github.com/spf13/cast v1.3.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/stretchr/objx v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1084 // indirect github.com/transip/gotransip/v6 v6.6.1 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1084 // indirect github.com/vinyldns/go-vinyldns v0.0.0-20200917153823-148a5f6b8f14 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect github.com/vultr/govultr/v2 v2.7.1 // indirect
github.com/transip/gotransip/v6 v6.26.0 // indirect go.opencensus.io v0.22.3 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect go.uber.org/ratelimit v0.0.0-20180316092928-c15da0234277 // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect golang.org/x/crypto v0.14.0 // indirect
github.com/volcengine/volc-sdk-golang v1.0.193 // indirect golang.org/x/net v0.17.0 // indirect
github.com/vultr/govultr/v3 v3.14.1 // indirect golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d // indirect
github.com/x448/float16 v0.8.4 // indirect golang.org/x/sys v0.13.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/text v0.13.0 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20241220122821-aeb3b05efd1c // indirect golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6 // indirect
github.com/yandex-cloud/go-sdk v0.0.0-20241220131134-2393e243c134 // indirect google.golang.org/api v0.20.0 // indirect
go.mongodb.org/mongo-driver v1.17.2 // indirect google.golang.org/appengine v1.6.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect google.golang.org/genproto v0.0.0-20200305110556-506484158171 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect google.golang.org/grpc v1.27.1 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect google.golang.org/protobuf v1.26.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect gopkg.in/ini.v1 v1.62.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect gopkg.in/ns1/ns1-go.v2 v2.6.2 // indirect
go.uber.org/atomic v1.11.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/api v0.217.0 // indirect
google.golang.org/genproto v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/grpc v1.69.4 // indirect
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
k8s.io/api v0.32.1 // indirect xorm.io/builder v0.3.12 // indirect
k8s.io/apimachinery v0.32.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
xorm.io/builder v0.3.13 // indirect
) )

2429
go.sum

File diff suppressed because it is too large Load diff

View file

@ -3,8 +3,6 @@ package html
import ( import (
_ "embed" _ "embed"
"net/http" "net/http"
"os"
"path"
"text/template" // do not use html/template here, we sanitize the message before passing it to the template "text/template" // do not use html/template here, we sanitize the message before passing it to the template
"codeberg.org/codeberg/pages/server/context" "codeberg.org/codeberg/pages/server/context"
@ -16,7 +14,7 @@ import (
var errorPage string var errorPage string
var ( var (
errorTemplate = template.Must(template.New("error").Parse(loadCustomTemplateOrDefault())) errorTemplate = template.Must(template.New("error").Parse(errorPage))
sanitizer = createBlueMondayPolicy() sanitizer = createBlueMondayPolicy()
) )
@ -53,19 +51,3 @@ func createBlueMondayPolicy() *bluemonday.Policy {
return p return p
} }
func loadCustomTemplateOrDefault() string {
contents, err := os.ReadFile("custom/error.html")
if err != nil {
if !os.IsNotExist(err) {
wd, wdErr := os.Getwd()
if wdErr != nil {
log.Err(err).Msg("could not load custom error page 'custom/error.html'")
} else {
log.Err(err).Msgf("could not load custom error page '%v'", path.Join(wd, "custom/error.html"))
}
}
return errorPage
}
return string(contents)
}

View file

@ -1,12 +1,18 @@
<!doctype html> <!DOCTYPE html>
<html class="codeberg-design"> <html class="codeberg-design">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<title>{{.StatusText}}</title> <title>{{.StatusText}}</title>
<link rel="stylesheet" href="https://design.codeberg.org/design-kit/codeberg.css" /> <link
<link rel="stylesheet" href="https://fonts.codeberg.org/dist/inter/Inter%20Web/inter.css" /> rel="stylesheet"
href="https://design.codeberg.org/design-kit/codeberg.css"
/>
<link
rel="stylesheet"
href="https://fonts.codeberg.org/dist/inter/Inter%20Web/inter.css"
/>
<style> <style>
body { body {
@ -28,29 +34,34 @@
</style> </style>
</head> </head>
<body> <body>
<svg xmlns="http://www.w3.org/2000/svg" height="10em" viewBox="0 0 24 24" fill="var(--blue-color)"> <svg
xmlns="http://www.w3.org/2000/svg"
height="10em"
viewBox="0 0 24 24"
fill="var(--blue-color)"
>
<path <path
d="M 9 2 C 5.1458514 2 2 5.1458514 2 9 C 2 12.854149 5.1458514 16 9 16 C 10.747998 16 12.345009 15.348024 13.574219 14.28125 L 14 14.707031 L 14 16 L 19.585938 21.585938 C 20.137937 22.137937 21.033938 22.137938 21.585938 21.585938 C 22.137938 21.033938 22.137938 20.137938 21.585938 19.585938 L 16 14 L 14.707031 14 L 14.28125 13.574219 C 15.348024 12.345009 16 10.747998 16 9 C 16 5.1458514 12.854149 2 9 2 z M 9 4 C 11.773268 4 14 6.2267316 14 9 C 14 11.773268 11.773268 14 9 14 C 6.2267316 14 4 11.773268 4 9 C 4 6.2267316 6.2267316 4 9 4 z" d="M 9 2 C 5.1458514 2 2 5.1458514 2 9 C 2 12.854149 5.1458514 16 9 16 C 10.747998 16 12.345009 15.348024 13.574219 14.28125 L 14 14.707031 L 14 16 L 19.585938 21.585938 C 20.137937 22.137937 21.033938 22.137938 21.585938 21.585938 C 22.137938 21.033938 22.137938 20.137938 21.585938 19.585938 L 16 14 L 14.707031 14 L 14.28125 13.574219 C 15.348024 12.345009 16 10.747998 16 9 C 16 5.1458514 12.854149 2 9 2 z M 9 4 C 11.773268 4 14 6.2267316 14 9 C 14 11.773268 11.773268 14 9 14 C 6.2267316 14 4 11.773268 4 9 C 4 6.2267316 6.2267316 4 9 4 z"
/> />
</svg> </svg>
<h1 class="mb-0 text-primary">{{.StatusText}} (Error {{.StatusCode}})!</h1> <h1 class="mb-0 text-primary">{{.StatusText}} ({{.StatusCode}})!</h1>
<h5 class="text-center" style="max-width: 25em"> <h5 class="text-center" style="max-width: 25em">
<p>Sorry, but this page couldn't be served:</p> <p>Sorry, but this page couldn't be served.</p>
<p><b>"{{.Message}}"</b></p> <p><b>"{{.Message}}"</b></p>
<p> <p>
The page you tried to reach is hosted on Codeberg Pages, which might currently be experiencing technical We hope this isn't a problem on our end ;) - Make sure to check the
difficulties. If that is the case, it could take a little while until this page is available again. <a
</p> href="https://docs.codeberg.org/codeberg-pages/troubleshooting/"
<p> target="_blank"
Otherwise, this page might also be unavailable due to a configuration error. If you are the owner of this
website, please make sure to check the
<a href="https://docs.codeberg.org/codeberg-pages/troubleshooting/" target="_blank"
>troubleshooting section in the Docs</a >troubleshooting section in the Docs</a
>! >!
</p> </p>
</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"
/>
Static pages made easy - Static pages made easy -
<a href="https://codeberg.page">Codeberg Pages</a> <a href="https://codeberg.page">Codeberg Pages</a>
</small> </small>

View file

@ -10,10 +10,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/urfave/cli/v2" "codeberg.org/codeberg/pages/cmd"
cmd "codeberg.org/codeberg/pages/cli" "github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/server"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@ -33,7 +32,10 @@ func TestMain(m *testing.M) {
} }
func startServer(ctx context.Context) error { func startServer(ctx context.Context) error {
args := []string{"integration"} args := []string{
"--verbose",
"--acme-accept-terms", "true",
}
setEnvIfNotSet("ACME_API", "https://acme.mock.directory") setEnvIfNotSet("ACME_API", "https://acme.mock.directory")
setEnvIfNotSet("PAGES_DOMAIN", "localhost.mock.directory") setEnvIfNotSet("PAGES_DOMAIN", "localhost.mock.directory")
setEnvIfNotSet("RAW_DOMAIN", "raw.localhost.mock.directory") setEnvIfNotSet("RAW_DOMAIN", "raw.localhost.mock.directory")
@ -42,15 +44,10 @@ func startServer(ctx context.Context) error {
setEnvIfNotSet("HTTP_PORT", "8880") setEnvIfNotSet("HTTP_PORT", "8880")
setEnvIfNotSet("ENABLE_HTTP_SERVER", "true") setEnvIfNotSet("ENABLE_HTTP_SERVER", "true")
setEnvIfNotSet("DB_TYPE", "sqlite3") setEnvIfNotSet("DB_TYPE", "sqlite3")
setEnvIfNotSet("GITEA_ROOT", "https://codeberg.org")
setEnvIfNotSet("LOG_LEVEL", "trace")
setEnvIfNotSet("ENABLE_LFS_SUPPORT", "true")
setEnvIfNotSet("ENABLE_SYMLINK_SUPPORT", "true")
setEnvIfNotSet("ACME_ACCOUNT_CONFIG", "integration/acme-account.json")
app := cli.NewApp() app := cli.NewApp()
app.Name = "pages-server" app.Name = "pages-server"
app.Action = server.Serve app.Action = cmd.Serve
app.Flags = cmd.ServerFlags app.Flags = cmd.ServerFlags
go func() { go func() {

20
main.go
View file

@ -1,21 +1,29 @@
package main package main
import ( import (
"fmt"
"os" "os"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
"github.com/rs/zerolog/log" "github.com/urfave/cli/v2"
"codeberg.org/codeberg/pages/cli" "codeberg.org/codeberg/pages/cmd"
"codeberg.org/codeberg/pages/server" "codeberg.org/codeberg/pages/server/version"
) )
func main() { func main() {
app := cli.CreatePagesApp() app := cli.NewApp()
app.Action = server.Serve app.Name = "pages-server"
app.Version = version.Version
app.Usage = "pages server"
app.Action = cmd.Serve
app.Flags = cmd.ServerFlags
app.Commands = []*cli.Command{
cmd.Certs,
}
if err := app.Run(os.Args); err != nil { if err := app.Run(os.Args); err != nil {
log.Error().Err(err).Msg("A fatal error occurred") _, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
} }

View file

@ -1,27 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":maintainLockFilesWeekly",
":enablePreCommit",
"schedule:automergeDaily",
"schedule:weekends"
],
"automergeType": "branch",
"automergeMajor": false,
"automerge": true,
"prConcurrentLimit": 5,
"labels": ["dependencies"],
"packageRules": [
{
"matchManagers": ["gomod", "dockerfile"]
},
{
"groupName": "golang deps non-major",
"matchManagers": ["gomod"],
"matchUpdateTypes": ["minor", "patch"],
"extends": ["schedule:daily"]
}
],
"postUpdateOptions": ["gomodTidy", "gomodUpdateImportPaths"]
}

View file

@ -1,26 +0,0 @@
package acme
import (
"errors"
"fmt"
"codeberg.org/codeberg/pages/config"
"codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/certificates"
)
var ErrAcmeMissConfig = errors.New("ACME client has wrong config")
func CreateAcmeClient(cfg config.ACMEConfig, enableHTTPServer bool, challengeCache cache.ICache) (*certificates.AcmeClient, error) {
// check config
if (!cfg.AcceptTerms || (cfg.DNSProvider == "" && !cfg.NoDNS01)) && cfg.APIEndpoint != "https://acme.mock.directory" {
return nil, fmt.Errorf("%w: you must set $ACME_ACCEPT_TERMS and $DNS_PROVIDER or $NO_DNS_01, unless $ACME_API is set to https://acme.mock.directory", ErrAcmeMissConfig)
}
if cfg.EAB_HMAC != "" && cfg.EAB_KID == "" {
return nil, fmt.Errorf("%w: ACME_EAB_HMAC also needs ACME_EAB_KID to be set", ErrAcmeMissConfig)
} else if cfg.EAB_HMAC == "" && cfg.EAB_KID != "" {
return nil, fmt.Errorf("%w: ACME_EAB_KID also needs ACME_EAB_HMAC to be set", ErrAcmeMissConfig)
}
return certificates.NewAcmeClient(cfg, enableHTTPServer, challengeCache)
}

View file

@ -2,8 +2,7 @@ package cache
import "time" import "time"
// ICache is an interface that defines how the pages server interacts with the cache. type SetGetKey interface {
type ICache interface {
Set(key string, value interface{}, ttl time.Duration) error Set(key string, value interface{}, ttl time.Duration) error
Get(key string) (interface{}, bool) Get(key string) (interface{}, bool)
Remove(key string) Remove(key string)

View file

@ -2,6 +2,6 @@ package cache
import "github.com/OrlovEvgeny/go-mcache" import "github.com/OrlovEvgeny/go-mcache"
func NewInMemoryCache() ICache { func NewKeyValueCache() SetGetKey {
return mcache.New() return mcache.New()
} }

View file

@ -10,7 +10,6 @@ import (
"github.com/reugn/equalizer" "github.com/reugn/equalizer"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/config"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
) )
@ -29,8 +28,8 @@ type AcmeClient struct {
acmeClientCertificateLimitPerUser map[string]*equalizer.TokenBucket acmeClientCertificateLimitPerUser map[string]*equalizer.TokenBucket
} }
func NewAcmeClient(cfg config.ACMEConfig, enableHTTPServer bool, challengeCache cache.ICache) (*AcmeClient, error) { func NewAcmeClient(acmeAccountConf, acmeAPI, acmeMail, acmeEabHmac, acmeEabKID, dnsProvider string, acmeAcceptTerms, enableHTTPServer, acmeUseRateLimits bool, challengeCache cache.SetGetKey) (*AcmeClient, error) {
acmeConfig, err := setupAcmeConfig(cfg) acmeConfig, err := setupAcmeConfig(acmeAccountConf, acmeAPI, acmeMail, acmeEabHmac, acmeEabKID, acmeAcceptTerms)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -55,12 +54,15 @@ func NewAcmeClient(cfg config.ACMEConfig, enableHTTPServer bool, challengeCache
if err != nil { if err != nil {
log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only") log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only")
} else { } else {
if cfg.DNSProvider == "" { if dnsProvider == "" {
// using mock wildcard certs // using mock server, don't use wildcard certs
mainDomainAcmeClient = nil err := mainDomainAcmeClient.Challenge.SetTLSALPN01Provider(AcmeTLSChallengeProvider{challengeCache})
if err != nil {
log.Error().Err(err).Msg("Can't create TLS-ALPN-01 provider")
}
} else { } else {
// use DNS-Challenge https://go-acme.github.io/lego/dns/ // use DNS-Challenge https://go-acme.github.io/lego/dns/
provider, err := dns.NewDNSChallengeProviderByName(cfg.DNSProvider) provider, err := dns.NewDNSChallengeProviderByName(dnsProvider)
if err != nil { if err != nil {
return nil, fmt.Errorf("can not create DNS Challenge provider: %w", err) return nil, fmt.Errorf("can not create DNS Challenge provider: %w", err)
} }
@ -74,7 +76,7 @@ func NewAcmeClient(cfg config.ACMEConfig, enableHTTPServer bool, challengeCache
legoClient: acmeClient, legoClient: acmeClient,
dnsChallengerLegoClient: mainDomainAcmeClient, dnsChallengerLegoClient: mainDomainAcmeClient,
acmeUseRateLimits: cfg.UseRateLimits, acmeUseRateLimits: acmeUseRateLimits,
obtainLocks: sync.Map{}, obtainLocks: sync.Map{},

View file

@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"os" "os"
"codeberg.org/codeberg/pages/config"
"github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration" "github.com/go-acme/lego/v4/registration"
@ -17,27 +16,21 @@ import (
const challengePath = "/.well-known/acme-challenge/" const challengePath = "/.well-known/acme-challenge/"
func setupAcmeConfig(cfg config.ACMEConfig) (*lego.Config, error) { func setupAcmeConfig(configFile, acmeAPI, acmeMail, acmeEabHmac, acmeEabKID string, acmeAcceptTerms bool) (*lego.Config, error) {
var myAcmeAccount AcmeAccount var myAcmeAccount AcmeAccount
var myAcmeConfig *lego.Config var myAcmeConfig *lego.Config
if cfg.AccountConfigFile == "" { if account, err := os.ReadFile(configFile); err == nil {
return nil, fmt.Errorf("invalid acme config file: '%s'", cfg.AccountConfigFile) log.Info().Msgf("found existing acme account config file '%s'", configFile)
}
if account, err := os.ReadFile(cfg.AccountConfigFile); err == nil {
log.Info().Msgf("found existing acme account config file '%s'", cfg.AccountConfigFile)
if err := json.Unmarshal(account, &myAcmeAccount); err != nil { if err := json.Unmarshal(account, &myAcmeAccount); err != nil {
return nil, err return nil, err
} }
myAcmeAccount.Key, err = certcrypto.ParsePEMPrivateKey([]byte(myAcmeAccount.KeyPEM)) myAcmeAccount.Key, err = certcrypto.ParsePEMPrivateKey([]byte(myAcmeAccount.KeyPEM))
if err != nil { if err != nil {
return nil, err return nil, err
} }
myAcmeConfig = lego.NewConfig(&myAcmeAccount) myAcmeConfig = lego.NewConfig(&myAcmeAccount)
myAcmeConfig.CADirURL = cfg.APIEndpoint myAcmeConfig.CADirURL = acmeAPI
myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048 myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048
// Validate Config // Validate Config
@ -46,7 +39,6 @@ func setupAcmeConfig(cfg config.ACMEConfig) (*lego.Config, error) {
log.Info().Err(err).Msg("config validation failed, you might just delete the config file and let it recreate") log.Info().Err(err).Msg("config validation failed, you might just delete the config file and let it recreate")
return nil, fmt.Errorf("acme config validation failed: %w", err) return nil, fmt.Errorf("acme config validation failed: %w", err)
} }
return myAcmeConfig, nil return myAcmeConfig, nil
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return nil, err return nil, err
@ -59,20 +51,20 @@ func setupAcmeConfig(cfg config.ACMEConfig) (*lego.Config, error) {
return nil, err return nil, err
} }
myAcmeAccount = AcmeAccount{ myAcmeAccount = AcmeAccount{
Email: cfg.Email, Email: acmeMail,
Key: privateKey, Key: privateKey,
KeyPEM: string(certcrypto.PEMEncode(privateKey)), KeyPEM: string(certcrypto.PEMEncode(privateKey)),
} }
myAcmeConfig = lego.NewConfig(&myAcmeAccount) myAcmeConfig = lego.NewConfig(&myAcmeAccount)
myAcmeConfig.CADirURL = cfg.APIEndpoint myAcmeConfig.CADirURL = acmeAPI
myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048 myAcmeConfig.Certificate.KeyType = certcrypto.RSA2048
tempClient, err := lego.NewClient(myAcmeConfig) tempClient, err := lego.NewClient(myAcmeConfig)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only") log.Error().Err(err).Msg("Can't create ACME client, continuing with mock certs only")
} else { } else {
// accept terms & log in to EAB // accept terms & log in to EAB
if cfg.EAB_KID == "" || cfg.EAB_HMAC == "" { if acmeEabKID == "" || acmeEabHmac == "" {
reg, err := tempClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: cfg.AcceptTerms}) reg, err := tempClient.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: acmeAcceptTerms})
if err != nil { if err != nil {
log.Error().Err(err).Msg("Can't register ACME account, continuing with mock certs only") log.Error().Err(err).Msg("Can't register ACME account, continuing with mock certs only")
} else { } else {
@ -80,9 +72,9 @@ func setupAcmeConfig(cfg config.ACMEConfig) (*lego.Config, error) {
} }
} else { } else {
reg, err := tempClient.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{ reg, err := tempClient.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: cfg.AcceptTerms, TermsOfServiceAgreed: acmeAcceptTerms,
Kid: cfg.EAB_KID, Kid: acmeEabKID,
HmacEncoded: cfg.EAB_HMAC, HmacEncoded: acmeEabHmac,
}) })
if err != nil { if err != nil {
log.Error().Err(err).Msg("Can't register ACME account, continuing with mock certs only") log.Error().Err(err).Msg("Can't register ACME account, continuing with mock certs only")
@ -97,8 +89,8 @@ func setupAcmeConfig(cfg config.ACMEConfig) (*lego.Config, error) {
log.Error().Err(err).Msg("json.Marshalfailed, waiting for manual restart to avoid rate limits") log.Error().Err(err).Msg("json.Marshalfailed, waiting for manual restart to avoid rate limits")
select {} select {}
} }
log.Info().Msgf("new acme account created. write to config file '%s'", cfg.AccountConfigFile) log.Info().Msgf("new acme account created. write to config file '%s'", configFile)
err = os.WriteFile(cfg.AccountConfigFile, acmeAccountJSON, 0o600) err = os.WriteFile(configFile, acmeAccountJSON, 0o600)
if err != nil { if err != nil {
log.Error().Err(err).Msg("os.WriteFile failed, waiting for manual restart to avoid rate limits") log.Error().Err(err).Msg("os.WriteFile failed, waiting for manual restart to avoid rate limits")
select {} select {}

View file

@ -15,7 +15,7 @@ import (
) )
type AcmeTLSChallengeProvider struct { type AcmeTLSChallengeProvider struct {
challengeCache cache.ICache challengeCache cache.SetGetKey
} }
// make sure AcmeTLSChallengeProvider match Provider interface // make sure AcmeTLSChallengeProvider match Provider interface
@ -31,7 +31,7 @@ func (a AcmeTLSChallengeProvider) CleanUp(domain, _, _ string) error {
} }
type AcmeHTTPChallengeProvider struct { type AcmeHTTPChallengeProvider struct {
challengeCache cache.ICache challengeCache cache.SetGetKey
} }
// make sure AcmeHTTPChallengeProvider match Provider interface // make sure AcmeHTTPChallengeProvider match Provider interface
@ -46,7 +46,7 @@ func (a AcmeHTTPChallengeProvider) CleanUp(domain, token, _ string) error {
return nil return nil
} }
func SetupHTTPACMEChallengeServer(challengeCache cache.ICache, sslPort uint) http.HandlerFunc { func SetupHTTPACMEChallengeServer(challengeCache cache.SetGetKey, sslPort uint) http.HandlerFunc {
// handle custom-ssl-ports to be added on https redirects // handle custom-ssl-ports to be added on https redirects
portPart := "" portPart := ""
if sslPort != 443 { if sslPort != 443 {

View file

@ -14,13 +14,10 @@ import (
"github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/go-acme/lego/v4/challenge/tlsalpn01"
"github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/lego"
"github.com/hashicorp/golang-lru/v2/expirable"
"github.com/reugn/equalizer" "github.com/reugn/equalizer"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
psContext "codeberg.org/codeberg/pages/server/context"
"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/gitea"
@ -34,22 +31,13 @@ func TLSConfig(mainDomainSuffix string,
giteaClient *gitea.Client, giteaClient *gitea.Client,
acmeClient *AcmeClient, acmeClient *AcmeClient,
firstDefaultBranch string, firstDefaultBranch string,
challengeCache, canonicalDomainCache cache.ICache, keyCache, challengeCache, dnsLookupCache, canonicalDomainCache cache.SetGetKey,
certDB database.CertDB, certDB database.CertDB,
noDNS01 bool,
rawDomain string,
) *tls.Config { ) *tls.Config {
// every cert is at most 24h in the cache and 7 days before expiry the cert is renewed
keyCache := expirable.NewLRU[string, *tls.Certificate](32, nil, 24*time.Hour)
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) {
ctx := psContext.New(nil, nil)
log := log.With().Str("ReqId", ctx.ReqId).Logger()
domain := strings.ToLower(strings.TrimSpace(info.ServerName)) domain := strings.ToLower(strings.TrimSpace(info.ServerName))
log.Debug().Str("domain", domain).Msg("start: get tls certificate")
if len(domain) < 1 { if len(domain) < 1 {
return nil, errors.New("missing domain info via SNI (RFC 4366, Section 3.1)") return nil, errors.New("missing domain info via SNI (RFC 4366, Section 3.1)")
} }
@ -76,27 +64,12 @@ func TLSConfig(mainDomainSuffix string,
targetOwner := "" targetOwner := ""
mayObtainCert := true mayObtainCert := true
if strings.HasSuffix(domain, mainDomainSuffix) || strings.EqualFold(domain, mainDomainSuffix[1:]) { if strings.HasSuffix(domain, mainDomainSuffix) || strings.EqualFold(domain, mainDomainSuffix[1:]) {
if noDNS01 {
// Limit the domains allowed to request a certificate to pages-server domains
// and domains for an existing user of org
if !strings.EqualFold(domain, mainDomainSuffix[1:]) && !strings.EqualFold(domain, rawDomain) {
targetOwner := strings.TrimSuffix(domain, mainDomainSuffix)
owner_exist, err := giteaClient.GiteaCheckIfOwnerExists(targetOwner)
mayObtainCert = owner_exist
if err != nil {
log.Error().Err(err).Msgf("Failed to check '%s' existence on the forge: %s", targetOwner, err)
mayObtainCert = false
}
}
} else {
// deliver default certificate for the main domain (*.codeberg.page) // deliver default certificate for the main domain (*.codeberg.page)
domain = mainDomainSuffix domain = mainDomainSuffix
}
} else { } else {
var targetRepo, targetBranch string var targetRepo, targetBranch string
targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch) targetOwner, targetRepo, targetBranch = dnsutils.GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch, dnsLookupCache)
if targetOwner == "" { if targetOwner == "" {
// DNS not set up, return main certificate to redirect to the docs // DNS not set up, return main certificate to redirect to the docs
domain = mainDomainSuffix domain = mainDomainSuffix
@ -106,7 +79,7 @@ func TLSConfig(mainDomainSuffix string,
TargetRepo: targetRepo, TargetRepo: targetRepo,
TargetBranch: targetBranch, TargetBranch: targetBranch,
} }
_, valid := targetOpt.CheckCanonicalDomain(ctx, giteaClient, domain, mainDomainSuffix, canonicalDomainCache) _, valid := targetOpt.CheckCanonicalDomain(giteaClient, domain, mainDomainSuffix, canonicalDomainCache)
if !valid { if !valid {
// We shouldn't obtain a certificate when we cannot check if the // We shouldn't obtain a certificate when we cannot check if the
// repository has specified this domain in the `.domains` file. // repository has specified this domain in the `.domains` file.
@ -117,12 +90,12 @@ func TLSConfig(mainDomainSuffix string,
if tlsCertificate, ok := keyCache.Get(domain); ok { if tlsCertificate, ok := keyCache.Get(domain); ok {
// we can use an existing certificate object // we can use an existing certificate object
return tlsCertificate, nil return tlsCertificate.(*tls.Certificate), nil
} }
var tlsCertificate *tls.Certificate var tlsCertificate *tls.Certificate
var err error var err error
if tlsCertificate, err = acmeClient.retrieveCertFromDB(log, domain, mainDomainSuffix, false, certDB); err != nil { if tlsCertificate, err = acmeClient.retrieveCertFromDB(domain, mainDomainSuffix, false, certDB); err != nil {
if !errors.Is(err, database.ErrNotFound) { if !errors.Is(err, database.ErrNotFound) {
return nil, err return nil, err
} }
@ -136,14 +109,15 @@ func TLSConfig(mainDomainSuffix string,
return nil, fmt.Errorf("won't request certificate for %q", domain) return nil, fmt.Errorf("won't request certificate for %q", domain)
} }
tlsCertificate, err = acmeClient.obtainCert(log, acmeClient.legoClient, []string{domain}, nil, targetOwner, false, mainDomainSuffix, certDB) tlsCertificate, err = acmeClient.obtainCert(acmeClient.legoClient, []string{domain}, nil, targetOwner, false, mainDomainSuffix, certDB)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
keyCache.Add(domain, tlsCertificate) if err := keyCache.Set(domain, tlsCertificate, 15*time.Minute); err != nil {
return nil, err
}
return tlsCertificate, nil return tlsCertificate, nil
}, },
NextProtos: []string{ NextProtos: []string{
@ -179,7 +153,7 @@ func (c *AcmeClient) checkUserLimit(user string) error {
return nil return nil
} }
func (c *AcmeClient) retrieveCertFromDB(log zerolog.Logger, sni, mainDomainSuffix string, useDnsProvider bool, certDB database.CertDB) (*tls.Certificate, error) { func (c *AcmeClient) retrieveCertFromDB(sni, mainDomainSuffix string, useDnsProvider bool, certDB database.CertDB) (*tls.Certificate, error) {
// parse certificate from database // parse certificate from database
res, err := certDB.Get(sni) res, err := certDB.Get(sni)
if err != nil { if err != nil {
@ -195,14 +169,15 @@ func (c *AcmeClient) retrieveCertFromDB(log zerolog.Logger, sni, mainDomainSuffi
// TODO: document & put into own function // TODO: document & put into own function
if !strings.EqualFold(sni, mainDomainSuffix) { if !strings.EqualFold(sni, mainDomainSuffix) {
tlsCertificate.Leaf, err = leaf(&tlsCertificate) tlsCertificate.Leaf, err = x509.ParseCertificate(tlsCertificate.Certificate[0])
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("error parsing leaf tlsCert: %w", err)
} }
// renew certificates 7 days before they expire // renew certificates 7 days before they expire
if tlsCertificate.Leaf.NotAfter.Before(time.Now().Add(7 * 24 * time.Hour)) { if tlsCertificate.Leaf.NotAfter.Before(time.Now().Add(7 * 24 * time.Hour)) {
// TODO: use ValidTill of custom cert struct // TODO: use ValidTill of custom cert struct
if 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
nextTryUnix, err := strconv.ParseInt(string(res.CSR), 10, 64) nextTryUnix, err := strconv.ParseInt(string(res.CSR), 10, 64)
if err == nil && time.Now().Before(time.Unix(nextTryUnix, 0)) { if err == nil && time.Now().Before(time.Unix(nextTryUnix, 0)) {
@ -212,7 +187,7 @@ func (c *AcmeClient) retrieveCertFromDB(log zerolog.Logger, sni, mainDomainSuffi
// TODO: make a queue ? // TODO: make a queue ?
go (func() { go (func() {
res.CSR = nil // acme client doesn't like CSR to be set res.CSR = nil // acme client doesn't like CSR to be set
if _, err := c.obtainCert(log, c.legoClient, []string{sni}, res, "", useDnsProvider, mainDomainSuffix, certDB); err != nil { if _, err := c.obtainCert(c.legoClient, []string{sni}, res, "", useDnsProvider, mainDomainSuffix, certDB); err != nil {
log.Error().Msgf("Couldn't renew certificate for %s: %v", sni, err) log.Error().Msgf("Couldn't renew certificate for %s: %v", sni, err)
} }
})() })()
@ -222,8 +197,11 @@ func (c *AcmeClient) retrieveCertFromDB(log zerolog.Logger, sni, mainDomainSuffi
return &tlsCertificate, nil return &tlsCertificate, nil
} }
func (c *AcmeClient) obtainCert(log zerolog.Logger, acmeClient *lego.Client, domains []string, renew *certificate.Resource, user string, useDnsProvider bool, mainDomainSuffix string, keyDatabase database.CertDB) (*tls.Certificate, error) { func (c *AcmeClient) obtainCert(acmeClient *lego.Client, domains []string, renew *certificate.Resource, user string, useDnsProvider bool, mainDomainSuffix string, keyDatabase database.CertDB) (*tls.Certificate, error) {
name := strings.TrimPrefix(domains[0], "*") name := strings.TrimPrefix(domains[0], "*")
if useDnsProvider && len(domains[0]) > 0 && domains[0][0] == '*' {
domains = domains[1:]
}
// lock to avoid simultaneous requests // lock to avoid simultaneous requests
_, working := c.obtainLocks.LoadOrStore(name, struct{}{}) _, working := c.obtainLocks.LoadOrStore(name, struct{}{})
@ -232,7 +210,7 @@ func (c *AcmeClient) obtainCert(log zerolog.Logger, acmeClient *lego.Client, dom
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
_, working = c.obtainLocks.Load(name) _, working = c.obtainLocks.Load(name)
} }
cert, err := c.retrieveCertFromDB(log, name, mainDomainSuffix, useDnsProvider, keyDatabase) cert, err := c.retrieveCertFromDB(name, mainDomainSuffix, useDnsProvider, keyDatabase)
if err != nil { if err != nil {
return nil, fmt.Errorf("certificate failed in synchronous request: %w", err) return nil, fmt.Errorf("certificate failed in synchronous request: %w", err)
} }
@ -241,12 +219,8 @@ func (c *AcmeClient) obtainCert(log zerolog.Logger, acmeClient *lego.Client, dom
defer c.obtainLocks.Delete(name) defer c.obtainLocks.Delete(name)
if acmeClient == nil { if acmeClient == nil {
if useDnsProvider {
return mockCert(domains[0], "DNS ACME client is not defined", mainDomainSuffix, keyDatabase)
} else {
return mockCert(domains[0], "ACME client uninitialized. This is a server error, please report!", mainDomainSuffix, keyDatabase) return mockCert(domains[0], "ACME client uninitialized. This is a server error, please report!", mainDomainSuffix, keyDatabase)
} }
}
// request actual cert // request actual cert
var res *certificate.Resource var res *certificate.Resource
@ -299,7 +273,6 @@ func (c *AcmeClient) obtainCert(log zerolog.Logger, acmeClient *lego.Client, dom
} }
leaf, err := leaf(&tlsCertificate) leaf, err := leaf(&tlsCertificate)
if err == nil && leaf.NotAfter.After(time.Now()) { if err == nil && leaf.NotAfter.After(time.Now()) {
tlsCertificate.Leaf = leaf
// avoid sending a mock cert instead of a still valid cert, instead abuse CSR field to store time to try again at // avoid sending a mock cert instead of a still valid cert, instead abuse CSR field to store time to try again at
renew.CSR = []byte(strconv.FormatInt(time.Now().Add(6*time.Hour).Unix(), 10)) renew.CSR = []byte(strconv.FormatInt(time.Now().Add(6*time.Hour).Unix(), 10))
if err := keyDatabase.Put(name, renew); err != nil { if err := keyDatabase.Put(name, renew); err != nil {
@ -326,7 +299,7 @@ func (c *AcmeClient) obtainCert(log zerolog.Logger, acmeClient *lego.Client, dom
return &tlsCertificate, nil return &tlsCertificate, nil
} }
func SetupMainDomainCertificates(log zerolog.Logger, mainDomainSuffix string, acmeClient *AcmeClient, certDB database.CertDB) error { func SetupMainDomainCertificates(mainDomainSuffix string, acmeClient *AcmeClient, 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(mainDomainSuffix)
if err != nil && !errors.Is(err, database.ErrNotFound) { if err != nil && !errors.Is(err, database.ErrNotFound) {
@ -334,7 +307,7 @@ func SetupMainDomainCertificates(log zerolog.Logger, mainDomainSuffix string, ac
} }
if mainCertBytes == nil { if mainCertBytes == nil {
_, err = acmeClient.obtainCert(log, acmeClient.dnsChallengerLegoClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, nil, "", true, mainDomainSuffix, certDB) _, err = acmeClient.obtainCert(acmeClient.dnsChallengerLegoClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, nil, "", true, mainDomainSuffix, certDB)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Couldn't renew main domain certificate, continuing with mock certs only") log.Error().Err(err).Msg("Couldn't renew main domain certificate, continuing with mock certs only")
} }
@ -343,7 +316,7 @@ func SetupMainDomainCertificates(log zerolog.Logger, mainDomainSuffix string, ac
return nil return nil
} }
func MaintainCertDB(log zerolog.Logger, ctx context.Context, interval time.Duration, acmeClient *AcmeClient, mainDomainSuffix string, certDB database.CertDB) { func MaintainCertDB(ctx context.Context, interval time.Duration, acmeClient *AcmeClient, mainDomainSuffix string, certDB database.CertDB) {
for { for {
// delete expired certs that will be invalid until next clean up // delete expired certs that will be invalid until next clean up
threshold := time.Now().Add(interval) threshold := time.Now().Add(interval)
@ -381,7 +354,7 @@ func MaintainCertDB(log zerolog.Logger, ctx context.Context, interval time.Durat
} else if tlsCertificates[0].NotAfter.Before(time.Now().Add(30 * 24 * time.Hour)) { } else if tlsCertificates[0].NotAfter.Before(time.Now().Add(30 * 24 * time.Hour)) {
// renew main certificate 30 days before it expires // renew main certificate 30 days before it expires
go (func() { go (func() {
_, err = acmeClient.obtainCert(log, acmeClient.dnsChallengerLegoClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, res, "", true, mainDomainSuffix, certDB) _, err = acmeClient.obtainCert(acmeClient.dnsChallengerLegoClient, []string{"*" + mainDomainSuffix, mainDomainSuffix[1:]}, res, "", true, mainDomainSuffix, certDB)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Couldn't renew certificate for main domain") log.Error().Err(err).Msg("Couldn't renew certificate for main domain")
} }
@ -397,20 +370,11 @@ func MaintainCertDB(log zerolog.Logger, ctx context.Context, interval time.Durat
} }
} }
// leaf returns the parsed leaf certificate, either from c.Leaf or by parsing // leaf returns the parsed leaf certificate, either from c.leaf or by parsing
// the corresponding c.Certificate[0]. // the corresponding c.Certificate[0].
// After successfully parsing the cert c.Leaf gets set to the parsed cert.
func leaf(c *tls.Certificate) (*x509.Certificate, error) { func leaf(c *tls.Certificate) (*x509.Certificate, error) {
if c.Leaf != nil { if c.Leaf != nil {
return c.Leaf, nil return c.Leaf, nil
} }
return x509.ParseCertificate(c.Certificate[0])
leaf, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
return nil, fmt.Errorf("tlsCert - failed to parse leaf: %w", err)
}
c.Leaf = leaf
return leaf, err
} }

View file

@ -5,29 +5,19 @@ import (
"net/http" "net/http"
"codeberg.org/codeberg/pages/server/utils" "codeberg.org/codeberg/pages/server/utils"
"github.com/hashicorp/go-uuid"
"github.com/rs/zerolog/log"
) )
type Context struct { type Context struct {
RespWriter http.ResponseWriter RespWriter http.ResponseWriter
Req *http.Request Req *http.Request
StatusCode int StatusCode int
ReqId string
} }
func New(w http.ResponseWriter, r *http.Request) *Context { func New(w http.ResponseWriter, r *http.Request) *Context {
req_uuid, err := uuid.GenerateUUID()
if err != nil {
log.Error().Err(err).Msg("Failed to generate request id, assigning error value")
req_uuid = "ERROR"
}
return &Context{ return &Context{
RespWriter: w, RespWriter: w,
Req: r, Req: r,
StatusCode: http.StatusOK, StatusCode: http.StatusOK,
ReqId: req_uuid,
} }
} }

View file

@ -52,6 +52,7 @@ func (x xDB) Close() error {
func (x xDB) Put(domain string, cert *certificate.Resource) error { func (x xDB) Put(domain string, cert *certificate.Resource) error {
log.Trace().Str("domain", cert.Domain).Msg("inserting cert to db") log.Trace().Str("domain", cert.Domain).Msg("inserting cert to db")
domain = integrationTestReplacements(domain)
c, err := toCert(domain, cert) c, err := toCert(domain, cert)
if err != nil { if err != nil {
return err return err
@ -81,6 +82,7 @@ func (x xDB) Get(domain string) (*certificate.Resource, error) {
if domain[:1] == "." { if domain[:1] == "." {
domain = "*" + domain domain = "*" + domain
} }
domain = integrationTestReplacements(domain)
cert := new(Cert) cert := new(Cert)
log.Trace().Str("domain", domain).Msg("get cert from db") log.Trace().Str("domain", domain).Msg("get cert from db")
@ -97,6 +99,7 @@ func (x xDB) Delete(domain string) error {
if domain[:1] == "." { if domain[:1] == "." {
domain = "*" + domain domain = "*" + domain
} }
domain = integrationTestReplacements(domain)
log.Trace().Str("domain", domain).Msg("delete cert from db") log.Trace().Str("domain", domain).Msg("delete cert from db")
_, err := x.engine.ID(domain).Delete(new(Cert)) _, err := x.engine.ID(domain).Delete(new(Cert))
@ -136,3 +139,13 @@ func supportedDriver(driver string) bool {
return false return false
} }
} }
// integrationTestReplacements is needed because integration tests use a single domain cert,
// while production use a wildcard cert
// TODO: find a better way to handle this
func integrationTestReplacements(domainKey string) string {
if domainKey == "*.localhost.mock.directory" {
return "localhost.mock.directory"
}
return domainKey
}

View file

@ -5,26 +5,22 @@ import (
"strings" "strings"
"time" "time"
"github.com/hashicorp/golang-lru/v2/expirable" "codeberg.org/codeberg/pages/server/cache"
) )
const ( // lookupCacheTimeout specifies the timeout for the DNS lookup cache.
lookupCacheValidity = 30 * time.Second var lookupCacheTimeout = 15 * time.Minute
defaultPagesRepo = "pages"
)
// TODO(#316): refactor to not use global variables var defaultPagesRepo = "pages"
var lookupCache *expirable.LRU[string, string] = expirable.NewLRU[string, string](4096, nil, lookupCacheValidity)
// GetTargetFromDNS searches for CNAME or TXT entries on the request domain ending with MainDomainSuffix. // GetTargetFromDNS searches for CNAME or TXT entries on the request domain ending with MainDomainSuffix.
// If everything is fine, it returns the target data. // If everything is fine, it returns the target data.
func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string) (targetOwner, targetRepo, targetBranch string) { func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string, dnsLookupCache cache.SetGetKey) (targetOwner, targetRepo, targetBranch string) {
// Get CNAME or TXT // Get CNAME or TXT
var cname string var cname string
var err error var err error
if cachedName, ok := dnsLookupCache.Get(domain); ok {
if entry, ok := lookupCache.Get(domain); ok { cname = cachedName.(string)
cname = entry
} else { } else {
cname, err = net.LookupCNAME(domain) cname, err = net.LookupCNAME(domain)
cname = strings.TrimSuffix(cname, ".") cname = strings.TrimSuffix(cname, ".")
@ -42,7 +38,7 @@ func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string) (targ
} }
} }
} }
_ = lookupCache.Add(domain, cname) _ = dnsLookupCache.Set(domain, cname, lookupCacheTimeout)
} }
if cname == "" { if cname == "" {
return return

View file

@ -2,17 +2,14 @@ package gitea
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"time" "time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/context"
) )
const ( const (
@ -29,27 +26,23 @@ const (
// TODO: move as option into cache interface // TODO: move as option into cache interface
fileCacheTimeout = 5 * time.Minute fileCacheTimeout = 5 * time.Minute
// ownerExistenceCacheTimeout specifies the timeout for the existence of a repo/org
ownerExistenceCacheTimeout = 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.
fileCacheSizeLimit = int64(1000 * 1000) fileCacheSizeLimit = int64(1000 * 1000)
) )
type FileResponse struct { type FileResponse struct {
Exists bool `json:"exists"` Exists bool
IsSymlink bool `json:"isSymlink"` IsSymlink bool
ETag string `json:"eTag"` ETag string
MimeType string `json:"mimeType"` // uncompressed MIME type MimeType string
RawMime string `json:"rawMime"` // raw MIME type (if compressed, type of compression) Body []byte
Body []byte `json:"-"` // saved separately
} }
func (f FileResponse) IsEmpty() bool { func (f FileResponse) IsEmpty() bool {
return len(f.Body) == 0 return len(f.Body) != 0
} }
func (f FileResponse) createHttpResponse(cacheKey string, decompress bool) (header http.Header, statusCode int) { func (f FileResponse) createHttpResponse(cacheKey string) (header http.Header, statusCode int) {
header = make(http.Header) header = make(http.Header)
if f.Exists { if f.Exists {
@ -62,13 +55,7 @@ func (f FileResponse) createHttpResponse(cacheKey string, decompress bool) (head
header.Set(giteaObjectTypeHeader, objTypeSymlink) header.Set(giteaObjectTypeHeader, objTypeSymlink)
} }
header.Set(ETagHeader, f.ETag) header.Set(ETagHeader, f.ETag)
if decompress {
header.Set(ContentTypeHeader, f.MimeType) header.Set(ContentTypeHeader, f.MimeType)
} else {
header.Set(ContentTypeHeader, f.RawMime)
}
header.Set(ContentLengthHeader, fmt.Sprintf("%d", len(f.Body))) header.Set(ContentLengthHeader, fmt.Sprintf("%d", len(f.Body)))
header.Set(PagesCacheIndicatorHeader, "true") header.Set(PagesCacheIndicatorHeader, "true")
@ -77,67 +64,42 @@ func (f FileResponse) createHttpResponse(cacheKey string, decompress bool) (head
} }
type BranchTimestamp struct { type BranchTimestamp struct {
NotFound bool `json:"notFound"` Branch string
Branch string `json:"branch,omitempty"` Timestamp time.Time
Timestamp time.Time `json:"timestamp,omitempty"` notFound bool
} }
type writeCacheReader struct { type writeCacheReader struct {
originalReader io.ReadCloser originalReader io.ReadCloser
buffer *bytes.Buffer buffer *bytes.Buffer
fileResponse *FileResponse rileResponse *FileResponse
cacheKey string cacheKey string
cache cache.ICache cache cache.SetGetKey
hasError bool hasError bool
doNotCache bool
complete bool
log zerolog.Logger
} }
func (t *writeCacheReader) Read(p []byte) (n int, err error) { func (t *writeCacheReader) Read(p []byte) (n int, err error) {
t.log.Trace().Msgf("[cache] read %q", t.cacheKey)
n, err = t.originalReader.Read(p) n, err = t.originalReader.Read(p)
if err == io.EOF {
t.complete = true
}
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
t.log.Trace().Err(err).Msgf("[cache] original reader for %q has returned an error", t.cacheKey) log.Trace().Err(err).Msgf("[cache] original reader for %q has returned an error", t.cacheKey)
t.hasError = true t.hasError = true
} else if n > 0 { } else if n > 0 {
if t.buffer.Len()+n > int(fileCacheSizeLimit) {
t.doNotCache = true
t.buffer.Reset()
} else {
_, _ = t.buffer.Write(p[:n]) _, _ = t.buffer.Write(p[:n])
} }
}
return return
} }
func (t *writeCacheReader) Close() error { func (t *writeCacheReader) Close() error {
doWrite := !t.hasError && !t.doNotCache && t.complete if !t.hasError {
fc := *t.fileResponse fc := *t.rileResponse
fc.Body = t.buffer.Bytes() fc.Body = t.buffer.Bytes()
if doWrite { _ = t.cache.Set(t.cacheKey, fc, fileCacheTimeout)
jsonToCache, err := json.Marshal(fc)
if err != nil {
t.log.Trace().Err(err).Msgf("[cache] marshaling json for %q has returned an error", t.cacheKey+"|Metadata")
} }
err = t.cache.Set(t.cacheKey+"|Metadata", jsonToCache, fileCacheTimeout) log.Trace().Msgf("cacheReader for %q saved=%t closed", t.cacheKey, !t.hasError)
if err != nil {
t.log.Trace().Err(err).Msgf("[cache] writer for %q has returned an error", t.cacheKey+"|Metadata")
}
err = t.cache.Set(t.cacheKey+"|Body", fc.Body, fileCacheTimeout)
if err != nil {
t.log.Trace().Err(err).Msgf("[cache] writer for %q has returned an error", t.cacheKey+"|Body")
}
}
t.log.Trace().Msgf("cacheReader for %q saved=%t closed", t.cacheKey, doWrite)
return t.originalReader.Close() return t.originalReader.Close()
} }
func (f FileResponse) CreateCacheReader(ctx *context.Context, r io.ReadCloser, cache cache.ICache, cacheKey string) io.ReadCloser { func (f FileResponse) CreateCacheReader(r io.ReadCloser, cache cache.SetGetKey, cacheKey string) io.ReadCloser {
log := log.With().Str("ReqId", ctx.ReqId).Logger()
if r == nil || cache == nil || cacheKey == "" { if r == nil || cache == nil || cacheKey == "" {
log.Error().Msg("could not create CacheReader") log.Error().Msg("could not create CacheReader")
return nil return nil
@ -146,9 +108,8 @@ func (f FileResponse) CreateCacheReader(ctx *context.Context, r io.ReadCloser, c
return &writeCacheReader{ return &writeCacheReader{
originalReader: r, originalReader: r,
buffer: bytes.NewBuffer(make([]byte, 0)), buffer: bytes.NewBuffer(make([]byte, 0)),
fileResponse: &f, rileResponse: &f,
cache: cache, cache: cache,
cacheKey: cacheKey, cacheKey: cacheKey,
log: log,
} }
} }

View file

@ -2,7 +2,6 @@ package gitea
import ( import (
"bytes" "bytes"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -17,9 +16,7 @@ import (
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/config"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/context"
"codeberg.org/codeberg/pages/server/version" "codeberg.org/codeberg/pages/server/version"
) )
@ -30,7 +27,6 @@ const (
branchTimestampCacheKeyPrefix = "branchTime" branchTimestampCacheKeyPrefix = "branchTime"
defaultBranchCacheKeyPrefix = "defaultBranch" defaultBranchCacheKeyPrefix = "defaultBranch"
rawContentCacheKeyPrefix = "rawContent" rawContentCacheKeyPrefix = "rawContent"
ownerExistenceKeyPrefix = "ownerExist"
// pages server // pages server
PagesCacheIndicatorHeader = "X-Pages-Cache" PagesCacheIndicatorHeader = "X-Pages-Cache"
@ -44,13 +40,11 @@ const (
ETagHeader = "ETag" ETagHeader = "ETag"
ContentTypeHeader = "Content-Type" ContentTypeHeader = "Content-Type"
ContentLengthHeader = "Content-Length" ContentLengthHeader = "Content-Length"
ContentEncodingHeader = "Content-Encoding"
) )
type Client struct { type Client struct {
sdkClient *gitea.Client sdkClient *gitea.Client
sdkFileClient *gitea.Client responseCache cache.SetGetKey
responseCache cache.ICache
giteaRoot string giteaRoot string
@ -61,50 +55,43 @@ type Client struct {
defaultMimeType string defaultMimeType string
} }
func NewClient(cfg config.ForgeConfig, respCache cache.ICache) (*Client, error) { func NewClient(giteaRoot, giteaAPIToken string, respCache cache.SetGetKey, followSymlinks, supportLFS bool) (*Client, error) {
// url.Parse returns valid on almost anything... rootURL, err := url.Parse(giteaRoot)
rootURL, err := url.ParseRequestURI(cfg.Root)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid forgejo/gitea root url: %w", err) return nil, err
} }
giteaRoot := strings.TrimSuffix(rootURL.String(), "/") giteaRoot = strings.Trim(rootURL.String(), "/")
forbiddenMimeTypes := make(map[string]bool, len(cfg.ForbiddenMimeTypes)) stdClient := http.Client{Timeout: 10 * time.Second}
for _, mimeType := range cfg.ForbiddenMimeTypes {
forbiddenMimeTypes[mimeType] = true // TODO: pass down
var (
forbiddenMimeTypes map[string]bool
defaultMimeType string
)
if forbiddenMimeTypes == nil {
forbiddenMimeTypes = make(map[string]bool)
} }
defaultMimeType := cfg.DefaultMimeType
if defaultMimeType == "" { if defaultMimeType == "" {
defaultMimeType = "application/octet-stream" defaultMimeType = "application/octet-stream"
} }
sdkClient, err := gitea.NewClient( sdk, err := gitea.NewClient(
giteaRoot, giteaRoot,
gitea.SetHTTPClient(&http.Client{Timeout: 10 * time.Second}), gitea.SetHTTPClient(&stdClient),
gitea.SetToken(cfg.Token), gitea.SetToken(giteaAPIToken),
gitea.SetUserAgent("pages-server/"+version.Version),
)
if err != nil {
return nil, err
}
sdkFileClient, err := gitea.NewClient(
giteaRoot,
gitea.SetHTTPClient(&http.Client{Timeout: 1 * time.Hour}),
gitea.SetToken(cfg.Token),
gitea.SetUserAgent("pages-server/"+version.Version), gitea.SetUserAgent("pages-server/"+version.Version),
) )
return &Client{ return &Client{
sdkClient: sdkClient, sdkClient: sdk,
sdkFileClient: sdkFileClient,
responseCache: respCache, responseCache: respCache,
giteaRoot: giteaRoot, giteaRoot: giteaRoot,
followSymlinks: cfg.FollowSymlinks, followSymlinks: followSymlinks,
supportLFS: cfg.LFSEnabled, supportLFS: supportLFS,
forbiddenMimeTypes: forbiddenMimeTypes, forbiddenMimeTypes: forbiddenMimeTypes,
defaultMimeType: defaultMimeType, defaultMimeType: defaultMimeType,
@ -115,8 +102,8 @@ func (client *Client) ContentWebLink(targetOwner, targetRepo, branch, resource s
return path.Join(client.giteaRoot, targetOwner, targetRepo, "src/branch", branch, resource) return path.Join(client.giteaRoot, targetOwner, targetRepo, "src/branch", branch, resource)
} }
func (client *Client) GiteaRawContent(ctx *context.Context, targetOwner, targetRepo, ref, resource string) ([]byte, error) { func (client *Client) GiteaRawContent(targetOwner, targetRepo, ref, resource string) ([]byte, error) {
reader, _, _, err := client.ServeRawContent(ctx, targetOwner, targetRepo, ref, resource, false) reader, _, _, err := client.ServeRawContent(targetOwner, targetRepo, ref, resource)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -124,47 +111,31 @@ func (client *Client) GiteaRawContent(ctx *context.Context, targetOwner, targetR
return io.ReadAll(reader) return io.ReadAll(reader)
} }
func (client *Client) ServeRawContent(ctx *context.Context, targetOwner, targetRepo, ref, resource string, decompress bool) (io.ReadCloser, http.Header, int, error) { func (client *Client) ServeRawContent(targetOwner, targetRepo, ref, resource string) (io.ReadCloser, http.Header, int, error) {
cacheKey := fmt.Sprintf("%s/%s/%s|%s|%s", rawContentCacheKeyPrefix, targetOwner, targetRepo, ref, resource) cacheKey := fmt.Sprintf("%s/%s/%s|%s|%s", rawContentCacheKeyPrefix, targetOwner, targetRepo, ref, resource)
log := log.With().Str("ReqId", ctx.ReqId).Str("cache_key", cacheKey).Logger() log := log.With().Str("cache_key", cacheKey).Logger()
log.Trace().Msg("try file in cache")
// handle if cache entry exist // handle if cache entry exist
if cacheMetadata, ok := client.responseCache.Get(cacheKey + "|Metadata"); ok { if cache, ok := client.responseCache.Get(cacheKey); ok {
var cache FileResponse cache := cache.(FileResponse)
err := json.Unmarshal(cacheMetadata.([]byte), &cache) cachedHeader, cachedStatusCode := cache.createHttpResponse(cacheKey)
if err != nil { // TODO: check against some timestamp mismatch?!?
log.Error().Err(err).Msgf("[cache] failed to unmarshal metadata for: %s", cacheKey)
return nil, nil, http.StatusNotFound, err
}
if !cache.Exists {
return nil, nil, http.StatusNotFound, ErrorNotFound
}
body, ok := client.responseCache.Get(cacheKey + "|Body")
if !ok {
log.Error().Msgf("[cache] failed to get body for: %s", cacheKey)
return nil, nil, http.StatusNotFound, ErrorNotFound
}
cache.Body = body.([]byte)
cachedHeader, cachedStatusCode := cache.createHttpResponse(cacheKey, decompress)
if cache.Exists { if cache.Exists {
if cache.IsSymlink { if cache.IsSymlink {
linkDest := string(cache.Body) linkDest := string(cache.Body)
log.Debug().Msgf("[cache] follow symlink from %q to %q", resource, linkDest) log.Debug().Msgf("[cache] follow symlink from %q to %q", resource, linkDest)
return client.ServeRawContent(ctx, targetOwner, targetRepo, ref, linkDest, decompress) return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest)
} else { } else {
log.Debug().Msgf("[cache] return %d bytes", len(cache.Body)) log.Debug().Msg("[cache] return bytes")
return io.NopCloser(bytes.NewReader(cache.Body)), cachedHeader, cachedStatusCode, nil return io.NopCloser(bytes.NewReader(cache.Body)), cachedHeader, cachedStatusCode, nil
} }
} else { } else {
return nil, nil, http.StatusNotFound, ErrorNotFound return nil, cachedHeader, cachedStatusCode, ErrorNotFound
} }
} }
log.Trace().Msg("file not in cache")
// not in cache, open reader via gitea api // not in cache, open reader via gitea api
reader, resp, err := client.sdkFileClient.GetFileReader(targetOwner, targetRepo, ref, resource, client.supportLFS) reader, resp, err := client.sdkClient.GetFileReader(targetOwner, targetRepo, ref, resource, client.supportLFS)
if resp != nil { if resp != nil {
switch resp.StatusCode { switch resp.StatusCode {
case http.StatusOK: case http.StatusOK:
@ -186,36 +157,26 @@ func (client *Client) ServeRawContent(ctx *context.Context, targetOwner, targetR
linkDest = path.Join(path.Dir(resource), linkDest) linkDest = path.Join(path.Dir(resource), linkDest)
// we store symlink not content to reduce duplicates in cache // we store symlink not content to reduce duplicates in cache
fileResponse := FileResponse{ if err := client.responseCache.Set(cacheKey, FileResponse{
Exists: true, Exists: true,
IsSymlink: true, IsSymlink: true,
Body: []byte(linkDest), Body: []byte(linkDest),
ETag: resp.Header.Get(ETagHeader), ETag: resp.Header.Get(ETagHeader),
} }, fileCacheTimeout); err != nil {
log.Trace().Msgf("file response has %d bytes", len(fileResponse.Body))
jsonToCache, err := json.Marshal(fileResponse)
if err != nil {
log.Error().Err(err).Msgf("[cache] marshaling json metadata for %q has returned an error", cacheKey)
}
if err := client.responseCache.Set(cacheKey+"|Metadata", jsonToCache, fileCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write")
}
if err := client.responseCache.Set(cacheKey+"|Body", fileResponse.Body, fileCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write") log.Error().Err(err).Msg("[cache] error on cache write")
} }
log.Debug().Msgf("follow symlink from %q to %q", resource, linkDest) log.Debug().Msgf("follow symlink from %q to %q", resource, linkDest)
return client.ServeRawContent(ctx, targetOwner, targetRepo, ref, linkDest, decompress) return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest)
} }
} }
// now we are sure it's content so set the MIME type // now we are sure it's content so set the MIME type
mimeType, rawType := client.getMimeTypeByExtension(resource) mimeType := client.getMimeTypeByExtension(resource)
resp.Response.Header.Set(ContentTypeHeader, mimeType) resp.Response.Header.Set(ContentTypeHeader, mimeType)
if decompress {
resp.Response.Header.Set(ContentTypeHeader, mimeType) if !shouldRespBeSavedToCache(resp.Response) {
} else { return reader, resp.Response.Header, resp.StatusCode, err
resp.Response.Header.Set(ContentTypeHeader, rawType)
} }
// now we write to cache and respond at the same time // now we write to cache and respond at the same time
@ -223,16 +184,14 @@ func (client *Client) ServeRawContent(ctx *context.Context, targetOwner, targetR
Exists: true, Exists: true,
ETag: resp.Header.Get(ETagHeader), ETag: resp.Header.Get(ETagHeader),
MimeType: mimeType, MimeType: mimeType,
RawMime: rawType,
} }
return fileResp.CreateCacheReader(ctx, reader, client.responseCache, cacheKey), resp.Response.Header, resp.StatusCode, nil return fileResp.CreateCacheReader(reader, client.responseCache, cacheKey), resp.Response.Header, resp.StatusCode, nil
case http.StatusNotFound: case http.StatusNotFound:
jsonToCache, err := json.Marshal(FileResponse{ETag: resp.Header.Get(ETagHeader)}) if err := client.responseCache.Set(cacheKey, FileResponse{
if err != nil { Exists: false,
log.Error().Err(err).Msgf("[cache] marshaling json metadata for %q has returned an error", cacheKey) ETag: resp.Header.Get(ETagHeader),
} }, fileCacheTimeout); err != nil {
if err := client.responseCache.Set(cacheKey+"|Metadata", jsonToCache, fileCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write") log.Error().Err(err).Msg("[cache] error on cache write")
} }
@ -247,36 +206,21 @@ func (client *Client) ServeRawContent(ctx *context.Context, targetOwner, targetR
func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchName string) (*BranchTimestamp, error) { func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchName string) (*BranchTimestamp, error) {
cacheKey := fmt.Sprintf("%s/%s/%s/%s", branchTimestampCacheKeyPrefix, repoOwner, repoName, branchName) cacheKey := fmt.Sprintf("%s/%s/%s/%s", branchTimestampCacheKeyPrefix, repoOwner, repoName, branchName)
if stampRaw, ok := client.responseCache.Get(cacheKey); ok { if stamp, ok := client.responseCache.Get(cacheKey); ok && stamp != nil {
var stamp BranchTimestamp branchTimeStamp := stamp.(*BranchTimestamp)
err := json.Unmarshal(stampRaw.([]byte), &stamp) if branchTimeStamp.notFound {
if err != nil { log.Trace().Msgf("[cache] use branch %q not found", branchName)
log.Error().Err(err).Bytes("stamp", stampRaw.([]byte)).Msgf("[cache] failed to unmarshal timestamp for: %s", cacheKey)
return &BranchTimestamp{}, ErrorNotFound return &BranchTimestamp{}, ErrorNotFound
} }
if stamp.NotFound {
log.Trace().Msgf("[cache] branch %q does not exist", branchName)
return &BranchTimestamp{}, ErrorNotFound
} else {
log.Trace().Msgf("[cache] use branch %q exist", branchName) log.Trace().Msgf("[cache] use branch %q exist", branchName)
// This comes from the refactoring of the caching library. return branchTimeStamp, nil
// The branch as reported by the API was stored in the cache, and I'm not sure if there are
// situations where it differs from the name in the request, hence this is left here.
return &stamp, nil
}
} }
branch, resp, err := client.sdkClient.GetRepoBranch(repoOwner, repoName, branchName) branch, resp, err := client.sdkClient.GetRepoBranch(repoOwner, repoName, branchName)
if err != nil { if err != nil {
if resp != nil && resp.StatusCode == http.StatusNotFound { if resp != nil && resp.StatusCode == http.StatusNotFound {
log.Trace().Msgf("[cache] set cache branch %q not found", branchName) log.Trace().Msgf("[cache] set cache branch %q not found", branchName)
jsonToCache, err := json.Marshal(BranchTimestamp{NotFound: true}) if err := client.responseCache.Set(cacheKey, &BranchTimestamp{Branch: branchName, notFound: true}, branchExistenceCacheTimeout); err != nil {
if err != nil {
log.Error().Err(err).Msgf("[cache] marshaling empty timestamp for '%s' has returned an error", cacheKey)
}
if err := client.responseCache.Set(cacheKey, jsonToCache, branchExistenceCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write") log.Error().Err(err).Msg("[cache] error on cache write")
} }
return &BranchTimestamp{}, ErrorNotFound return &BranchTimestamp{}, ErrorNotFound
@ -293,11 +237,7 @@ func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchNam
} }
log.Trace().Msgf("set cache branch [%s] exist", branchName) log.Trace().Msgf("set cache branch [%s] exist", branchName)
jsonToCache, err := json.Marshal(stamp) if err := client.responseCache.Set(cacheKey, stamp, branchExistenceCacheTimeout); err != nil {
if err != nil {
log.Error().Err(err).Msgf("[cache] marshaling timestamp for %q has returned an error", cacheKey)
}
if err := client.responseCache.Set(cacheKey, jsonToCache, branchExistenceCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write") log.Error().Err(err).Msg("[cache] error on cache write")
} }
return stamp, nil return stamp, nil
@ -306,8 +246,8 @@ func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchNam
func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (string, error) { func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (string, error) {
cacheKey := fmt.Sprintf("%s/%s/%s", defaultBranchCacheKeyPrefix, repoOwner, repoName) cacheKey := fmt.Sprintf("%s/%s/%s", defaultBranchCacheKeyPrefix, repoOwner, repoName)
if branch, ok := client.responseCache.Get(cacheKey); ok { if branch, ok := client.responseCache.Get(cacheKey); ok && branch != nil {
return string(branch.([]byte)), nil return branch.(string), nil
} }
repo, resp, err := client.sdkClient.GetRepo(repoOwner, repoName) repo, resp, err := client.sdkClient.GetRepo(repoOwner, repoName)
@ -319,68 +259,37 @@ func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (str
} }
branch := repo.DefaultBranch branch := repo.DefaultBranch
if err := client.responseCache.Set(cacheKey, []byte(branch), defaultBranchCacheTimeout); err != nil { if err := client.responseCache.Set(cacheKey, branch, defaultBranchCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write") log.Error().Err(err).Msg("[cache] error on cache write")
} }
return branch, nil return branch, nil
} }
func (client *Client) GiteaCheckIfOwnerExists(owner string) (bool, error) { func (client *Client) getMimeTypeByExtension(resource string) string {
cacheKey := fmt.Sprintf("%s/%s", ownerExistenceKeyPrefix, owner) mimeType := mime.TypeByExtension(path.Ext(resource))
if existRaw, ok := client.responseCache.Get(cacheKey); ok && existRaw != nil {
exist, err := strconv.ParseBool(existRaw.(string))
return exist, err
}
_, resp, err := client.sdkClient.GetUserInfo(owner)
if resp.StatusCode == http.StatusOK && err == nil {
if err := client.responseCache.Set(cacheKey, []byte("true"), ownerExistenceCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write")
}
return true, nil
} else if resp.StatusCode != http.StatusNotFound {
return false, err
}
_, resp, err = client.sdkClient.GetOrg(owner)
if resp.StatusCode == http.StatusOK && err == nil {
if err := client.responseCache.Set(cacheKey, []byte("true"), ownerExistenceCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write")
}
return true, nil
} else if resp.StatusCode != http.StatusNotFound {
return false, err
}
if err := client.responseCache.Set(cacheKey, []byte("false"), ownerExistenceCacheTimeout); err != nil {
log.Error().Err(err).Msg("[cache] error on cache write")
}
return false, nil
}
func (client *Client) extToMime(ext string) string {
mimeType := mime.TypeByExtension(path.Ext(ext))
mimeTypeSplit := strings.SplitN(mimeType, ";", 2) mimeTypeSplit := strings.SplitN(mimeType, ";", 2)
if client.forbiddenMimeTypes[mimeTypeSplit[0]] || mimeType == "" { if client.forbiddenMimeTypes[mimeTypeSplit[0]] || mimeType == "" {
mimeType = client.defaultMimeType mimeType = client.defaultMimeType
} }
log.Trace().Msgf("probe mime of extension '%q' is '%q'", ext, mimeType) log.Trace().Msgf("probe mime of %q is %q", resource, mimeType)
return mimeType return mimeType
} }
func (client *Client) getMimeTypeByExtension(resource string) (mimeType, rawType string) { func shouldRespBeSavedToCache(resp *http.Response) bool {
rawExt := path.Ext(resource) if resp == nil {
innerExt := rawExt return false
switch rawExt {
case ".gz", ".br", ".zst":
innerExt = path.Ext(resource[:len(resource)-len(rawExt)])
} }
rawType = client.extToMime(rawExt)
mimeType = rawType contentLengthRaw := resp.Header.Get(ContentLengthHeader)
if innerExt != rawExt { if contentLengthRaw == "" {
mimeType = client.extToMime(innerExt) return false
} }
log.Trace().Msgf("probe mime of %q is (%q / raw %q)", resource, mimeType, rawType)
return mimeType, rawType contentLength, err := strconv.ParseInt(contentLengthRaw, 10, 64)
if err != nil {
log.Error().Err(err).Msg("could not parse content length")
}
// if content to big or could not be determined we not cache it
return contentLength > 0 && contentLength < fileCacheSizeLimit
} }

View file

@ -6,7 +6,6 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/config"
"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/context" "codeberg.org/codeberg/pages/server/context"
@ -20,15 +19,15 @@ const (
) )
// Handler handles a single HTTP request to the web server. // Handler handles a single HTTP request to the web server.
func Handler( func Handler(mainDomainSuffix, rawDomain string,
cfg config.ServerConfig,
giteaClient *gitea.Client, giteaClient *gitea.Client,
canonicalDomainCache, redirectsCache cache.ICache, blacklistedPaths, allowedCorsDomains []string,
defaultPagesBranches []string,
dnsLookupCache, canonicalDomainCache, redirectsCache cache.SetGetKey,
) http.HandlerFunc { ) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
log := log.With().Strs("Handler", []string{req.Host, req.RequestURI}).Logger()
ctx := context.New(w, req) ctx := context.New(w, req)
log := log.With().Str("ReqId", ctx.ReqId).Strs("Handler", []string{req.Host, req.RequestURI}).Logger()
log.Debug().Msg("\n----------------------------------------------------------")
ctx.RespWriter.Header().Set("Server", "pages-server") ctx.RespWriter.Header().Set("Server", "pages-server")
@ -40,8 +39,8 @@ func Handler(
trimmedHost := ctx.TrimHostPort() trimmedHost := ctx.TrimHostPort()
// Add HSTS for RawDomain and MainDomain // Add HSTS for RawDomain and MainDomainSuffix
if hsts := getHSTSHeader(trimmedHost, cfg.MainDomain, cfg.RawDomain); hsts != "" { if hsts := getHSTSHeader(trimmedHost, mainDomainSuffix, rawDomain); hsts != "" {
ctx.RespWriter.Header().Set("Strict-Transport-Security", hsts) ctx.RespWriter.Header().Set("Strict-Transport-Security", hsts)
} }
@ -63,7 +62,7 @@ func Handler(
} }
// Block blacklisted paths (like ACME challenges) // Block blacklisted paths (like ACME challenges)
for _, blacklistedPath := range cfg.BlacklistedPaths { for _, blacklistedPath := range blacklistedPaths {
if strings.HasPrefix(ctx.Path(), blacklistedPath) { if strings.HasPrefix(ctx.Path(), blacklistedPath) {
html.ReturnErrorPage(ctx, "requested path is blacklisted", http.StatusForbidden) html.ReturnErrorPage(ctx, "requested path is blacklisted", http.StatusForbidden)
return return
@ -72,7 +71,7 @@ func Handler(
// Allow CORS for specified domains // Allow CORS for specified domains
allowCors := false allowCors := false
for _, allowedCorsDomain := range cfg.AllowedCorsDomains { for _, allowedCorsDomain := range allowedCorsDomains {
if strings.EqualFold(trimmedHost, allowedCorsDomain) { if strings.EqualFold(trimmedHost, allowedCorsDomain) {
allowCors = true allowCors = true
break break
@ -86,29 +85,29 @@ func Handler(
// Prepare request information to Gitea // Prepare request information to Gitea
pathElements := strings.Split(strings.Trim(ctx.Path(), "/"), "/") pathElements := strings.Split(strings.Trim(ctx.Path(), "/"), "/")
if cfg.RawDomain != "" && strings.EqualFold(trimmedHost, cfg.RawDomain) { if rawDomain != "" && strings.EqualFold(trimmedHost, rawDomain) {
log.Debug().Msg("raw domain request detected") log.Debug().Msg("raw domain request detected")
handleRaw(log, ctx, giteaClient, handleRaw(log, ctx, giteaClient,
cfg.MainDomain, mainDomainSuffix,
trimmedHost, trimmedHost,
pathElements, pathElements,
canonicalDomainCache, redirectsCache) canonicalDomainCache, redirectsCache)
} else if strings.HasSuffix(trimmedHost, cfg.MainDomain) { } else if strings.HasSuffix(trimmedHost, mainDomainSuffix) {
log.Debug().Msg("subdomain request detected") log.Debug().Msg("subdomain request detected")
handleSubDomain(log, ctx, giteaClient, handleSubDomain(log, ctx, giteaClient,
cfg.MainDomain, mainDomainSuffix,
cfg.PagesBranches, defaultPagesBranches,
trimmedHost, trimmedHost,
pathElements, pathElements,
canonicalDomainCache, redirectsCache) canonicalDomainCache, redirectsCache)
} else { } else {
log.Debug().Msg("custom domain request detected") log.Debug().Msg("custom domain request detected")
handleCustomDomain(log, ctx, giteaClient, handleCustomDomain(log, ctx, giteaClient,
cfg.MainDomain, mainDomainSuffix,
trimmedHost, trimmedHost,
pathElements, pathElements,
cfg.PagesBranches[0], defaultPagesBranches[0],
canonicalDomainCache, redirectsCache) dnsLookupCache, canonicalDomainCache, redirectsCache)
} }
} }
} }

View file

@ -19,10 +19,10 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g
trimmedHost string, trimmedHost string,
pathElements []string, pathElements []string,
firstDefaultBranch string, firstDefaultBranch string,
canonicalDomainCache, redirectsCache cache.ICache, dnsLookupCache, canonicalDomainCache, redirectsCache cache.SetGetKey,
) { ) {
// Serve pages from custom domains // Serve pages from custom domains
targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, firstDefaultBranch) targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, firstDefaultBranch, dnsLookupCache)
if targetOwner == "" { if targetOwner == "" {
html.ReturnErrorPage(ctx, html.ReturnErrorPage(ctx,
"could not obtain repo owner from custom domain", "could not obtain repo owner from custom domain",
@ -47,13 +47,13 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g
TargetBranch: targetBranch, TargetBranch: targetBranch,
TargetPath: path.Join(pathParts...), TargetPath: path.Join(pathParts...),
}, canonicalLink); works { }, canonicalLink); works {
canonicalDomain, valid := targetOpt.CheckCanonicalDomain(ctx, giteaClient, trimmedHost, mainDomainSuffix, canonicalDomainCache) canonicalDomain, valid := targetOpt.CheckCanonicalDomain(giteaClient, trimmedHost, mainDomainSuffix, canonicalDomainCache)
if !valid { if !valid {
html.ReturnErrorPage(ctx, "domain not specified in <code>.domains</code> file", http.StatusMisdirectedRequest) html.ReturnErrorPage(ctx, "domain not specified in <code>.domains</code> file", http.StatusMisdirectedRequest)
return return
} else if canonicalDomain != trimmedHost { } else if canonicalDomain != trimmedHost {
// only redirect if the target is also a codeberg page! // only redirect if the target is also a codeberg page!
targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, firstDefaultBranch) targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, firstDefaultBranch, dnsLookupCache)
if targetOwner != "" { if targetOwner != "" {
ctx.Redirect("https://"+canonicalDomain+"/"+targetOpt.TargetPath, http.StatusTemporaryRedirect) ctx.Redirect("https://"+canonicalDomain+"/"+targetOpt.TargetPath, http.StatusTemporaryRedirect)
return return
@ -63,8 +63,8 @@ func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *g
return return
} }
log.Debug().Str("url", trimmedHost).Msg("tryBranch, now trying upstream") log.Debug().Msg("tryBranch, now trying upstream 7")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return return
} }

View file

@ -19,7 +19,7 @@ func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Clie
mainDomainSuffix string, mainDomainSuffix string,
trimmedHost string, trimmedHost string,
pathElements []string, pathElements []string,
canonicalDomainCache, redirectsCache cache.ICache, canonicalDomainCache, redirectsCache cache.SetGetKey,
) { ) {
// Serve raw content from RawDomain // Serve raw content from RawDomain
log.Debug().Msg("raw domain") log.Debug().Msg("raw domain")
@ -45,7 +45,7 @@ func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Clie
TargetPath: path.Join(pathElements[3:]...), TargetPath: path.Join(pathElements[3:]...),
}, true); works { }, true); works {
log.Trace().Msg("tryUpstream: serve raw domain with specified branch") log.Trace().Msg("tryUpstream: serve raw domain with specified branch")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return return
} }
log.Debug().Msg("missing branch info") log.Debug().Msg("missing branch info")
@ -62,7 +62,7 @@ func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Clie
TargetPath: path.Join(pathElements[2:]...), TargetPath: path.Join(pathElements[2:]...),
}, true); works { }, true); works {
log.Trace().Msg("tryUpstream: serve raw domain with default branch") log.Trace().Msg("tryUpstream: serve raw domain with default branch")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
} else { } else {
html.ReturnErrorPage(ctx, html.ReturnErrorPage(ctx,
fmt.Sprintf("raw domain could not find repo <code>%s/%s</code> or repo is empty", targetOpt.TargetOwner, targetOpt.TargetRepo), fmt.Sprintf("raw domain could not find repo <code>%s/%s</code> or repo is empty", targetOpt.TargetOwner, targetOpt.TargetRepo),

View file

@ -21,7 +21,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
defaultPagesBranches []string, defaultPagesBranches []string,
trimmedHost string, trimmedHost string,
pathElements []string, pathElements []string,
canonicalDomainCache, redirectsCache cache.ICache, canonicalDomainCache, redirectsCache cache.SetGetKey,
) { ) {
// Serve pages from subdomains of MainDomainSuffix // Serve pages from subdomains of MainDomainSuffix
log.Debug().Msg("main domain suffix") log.Debug().Msg("main domain suffix")
@ -53,7 +53,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
TargetPath: path.Join(pathElements[2:]...), TargetPath: path.Join(pathElements[2:]...),
}, true); works { }, true); works {
log.Trace().Msg("tryUpstream: serve with specified repo and branch") log.Trace().Msg("tryUpstream: serve with specified repo and branch")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
} else { } else {
html.ReturnErrorPage( html.ReturnErrorPage(
ctx, ctx,
@ -85,7 +85,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
TargetPath: path.Join(pathElements[1:]...), TargetPath: path.Join(pathElements[1:]...),
}, true); works { }, true); works {
log.Trace().Msg("tryUpstream: serve default pages repo with specified branch") log.Trace().Msg("tryUpstream: serve default pages repo with specified branch")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
} else { } else {
html.ReturnErrorPage( html.ReturnErrorPage(
ctx, ctx,
@ -110,7 +110,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
TargetPath: path.Join(pathElements[1:]...), TargetPath: path.Join(pathElements[1:]...),
}, false); works { }, false); works {
log.Debug().Msg("tryBranch, now trying upstream 5") log.Debug().Msg("tryBranch, now trying upstream 5")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return return
} }
} }
@ -126,7 +126,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
TargetPath: path.Join(pathElements...), TargetPath: path.Join(pathElements...),
}, false); works { }, false); works {
log.Debug().Msg("tryBranch, now trying upstream 6") log.Debug().Msg("tryBranch, now trying upstream 6")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return return
} }
} }
@ -141,7 +141,7 @@ func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gite
TargetPath: path.Join(pathElements...), TargetPath: path.Join(pathElements...),
}, false); works { }, false); works {
log.Debug().Msg("tryBranch, now trying upstream 6") log.Debug().Msg("tryBranch, now trying upstream 6")
tryUpstream(log, ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache)
return return
} }

View file

@ -6,30 +6,23 @@ import (
"testing" "testing"
"time" "time"
"codeberg.org/codeberg/pages/config"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/gitea" "codeberg.org/codeberg/pages/server/gitea"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func TestHandlerPerformance(t *testing.T) { func TestHandlerPerformance(t *testing.T) {
cfg := config.ForgeConfig{ giteaClient, _ := gitea.NewClient("https://codeberg.org", "", cache.NewKeyValueCache(), false, false)
Root: "https://codeberg.org", testHandler := Handler(
Token: "", "codeberg.page", "raw.codeberg.org",
LFSEnabled: false, giteaClient,
FollowSymlinks: false, []string{"/.well-known/acme-challenge/"},
} []string{"raw.codeberg.org", "fonts.codeberg.org", "design.codeberg.org"},
giteaClient, _ := gitea.NewClient(cfg, cache.NewInMemoryCache()) []string{"pages"},
serverCfg := config.ServerConfig{ cache.NewKeyValueCache(),
MainDomain: "codeberg.page", cache.NewKeyValueCache(),
RawDomain: "raw.codeberg.page", cache.NewKeyValueCache(),
BlacklistedPaths: []string{ )
"/.well-known/acme-challenge/",
},
AllowedCorsDomains: []string{"raw.codeberg.org", "fonts.codeberg.org", "design.codeberg.org"},
PagesBranches: []string{"pages"},
}
testHandler := Handler(serverCfg, giteaClient, cache.NewInMemoryCache(), cache.NewInMemoryCache())
testCase := func(uri string, status int) { testCase := func(uri string, status int) {
t.Run(uri, func(t *testing.T) { t.Run(uri, func(t *testing.T) {

View file

@ -1,7 +1,6 @@
package handler package handler
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
@ -15,15 +14,15 @@ import (
) )
// 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(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client, func tryUpstream(ctx *context.Context, giteaClient *gitea.Client,
mainDomainSuffix, trimmedHost string, mainDomainSuffix, trimmedHost string,
options *upstream.Options, options *upstream.Options,
canonicalDomainCache cache.ICache, canonicalDomainCache cache.SetGetKey,
redirectsCache cache.ICache, redirectsCache 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 strings.HasSuffix(trimmedHost, mainDomainSuffix) && !options.ServeRaw { if strings.HasSuffix(trimmedHost, mainDomainSuffix) && !options.ServeRaw {
canonicalDomain, _ := options.CheckCanonicalDomain(ctx, giteaClient, "", mainDomainSuffix, canonicalDomainCache) canonicalDomain, _ := options.CheckCanonicalDomain(giteaClient, "", mainDomainSuffix, canonicalDomainCache)
if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix) { if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix) {
canonicalPath := ctx.Req.RequestURI canonicalPath := ctx.Req.RequestURI
if options.TargetRepo != defaultPagesRepo { if options.TargetRepo != defaultPagesRepo {
@ -32,12 +31,7 @@ func tryUpstream(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Cl
canonicalPath = "/" + path[2] canonicalPath = "/" + path[2]
} }
} }
ctx.Redirect("https://"+canonicalDomain+canonicalPath, http.StatusTemporaryRedirect)
redirect_to := "https://" + canonicalDomain + canonicalPath
log.Debug().Str("to", redirect_to).Msg("redirecting")
ctx.Redirect(redirect_to, http.StatusTemporaryRedirect)
return return
} }
} }
@ -46,9 +40,8 @@ func tryUpstream(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Cl
options.Host = trimmedHost options.Host = trimmedHost
// Try to request the file from the Gitea API // Try to request the file from the Gitea API
log.Debug().Msg("requesting from upstream")
if !options.Upstream(ctx, giteaClient, redirectsCache) { if !options.Upstream(ctx, giteaClient, redirectsCache) {
html.ReturnErrorPage(ctx, fmt.Sprintf("Forge returned %d %s", ctx.StatusCode, http.StatusText(ctx.StatusCode)), ctx.StatusCode) html.ReturnErrorPage(ctx, "gitea client failed", ctx.StatusCode)
} }
} }

View file

@ -1,21 +0,0 @@
package server
import (
"net/http"
_ "net/http/pprof"
"github.com/rs/zerolog/log"
)
func StartProfilingServer(listeningAddress string) {
server := &http.Server{
Addr: listeningAddress,
Handler: http.DefaultServeMux,
}
log.Info().Msgf("Starting debug server on %s", listeningAddress)
go func() {
log.Fatal().Err(server.ListenAndServe()).Msg("Failed to start debug server")
}()
}

View file

@ -1,145 +0,0 @@
package server
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/pires/go-proxyproto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
cmd "codeberg.org/codeberg/pages/cli"
"codeberg.org/codeberg/pages/config"
"codeberg.org/codeberg/pages/server/acme"
"codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/certificates"
"codeberg.org/codeberg/pages/server/gitea"
"codeberg.org/codeberg/pages/server/handler"
)
// Serve sets up and starts the web server.
func Serve(ctx *cli.Context) error {
// initialize logger with Trace, overridden later with actual level
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Caller().Logger().Level(zerolog.TraceLevel)
cfg, err := config.ReadConfig(ctx)
if err != nil {
log.Error().Err(err).Msg("could not read config")
}
config.MergeConfig(ctx, cfg)
// Initialize the logger.
logLevel, err := zerolog.ParseLevel(cfg.LogLevel)
if err != nil {
return err
}
fmt.Printf("Setting log level to: %s\n", logLevel)
log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Caller().Logger().Level(logLevel)
listeningSSLAddress := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
listeningHTTPAddress := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.HttpPort)
if cfg.Server.RawDomain != "" {
cfg.Server.AllowedCorsDomains = append(cfg.Server.AllowedCorsDomains, cfg.Server.RawDomain)
}
// Make sure MainDomain has a leading dot
if !strings.HasPrefix(cfg.Server.MainDomain, ".") {
// TODO make this better
cfg.Server.MainDomain = "." + cfg.Server.MainDomain
}
if len(cfg.Server.PagesBranches) == 0 {
return fmt.Errorf("no default branches set (PAGES_BRANCHES)")
}
// Init ssl cert database
certDB, closeFn, err := cmd.OpenCertDB(ctx)
if err != nil {
return err
}
defer closeFn()
challengeCache := cache.NewInMemoryCache()
// canonicalDomainCache stores canonical domains
canonicalDomainCache := cache.NewInMemoryCache()
// redirectsCache stores redirects in _redirects files
redirectsCache := cache.NewInMemoryCache()
// clientResponseCache stores responses from the Gitea server
clientResponseCache := cache.NewInMemoryCache()
giteaClient, err := gitea.NewClient(cfg.Forge, clientResponseCache)
if err != nil {
return fmt.Errorf("could not create new gitea client: %v", err)
}
acmeClient, err := acme.CreateAcmeClient(cfg.ACME, cfg.Server.HttpServerEnabled, challengeCache)
if err != nil {
return err
}
if err := certificates.SetupMainDomainCertificates(log.Logger, cfg.Server.MainDomain, acmeClient, certDB); err != nil {
return err
}
// Create listener for SSL connections
log.Info().Msgf("Create TCP listener for SSL on %s", listeningSSLAddress)
listener, err := net.Listen("tcp", listeningSSLAddress)
if err != nil {
return fmt.Errorf("couldn't create listener: %v", err)
}
if cfg.Server.UseProxyProtocol {
listener = &proxyproto.Listener{Listener: listener}
}
// Setup listener for SSL connections
listener = tls.NewListener(listener, certificates.TLSConfig(
cfg.Server.MainDomain,
giteaClient,
acmeClient,
cfg.Server.PagesBranches[0],
challengeCache, canonicalDomainCache,
certDB,
cfg.ACME.NoDNS01,
cfg.Server.RawDomain,
))
interval := 12 * time.Hour
certMaintainCtx, cancelCertMaintain := context.WithCancel(context.Background())
defer cancelCertMaintain()
go certificates.MaintainCertDB(log.Logger, certMaintainCtx, interval, acmeClient, cfg.Server.MainDomain, certDB)
if cfg.Server.HttpServerEnabled {
// Create handler for http->https redirect and http acme challenges
httpHandler := certificates.SetupHTTPACMEChallengeServer(challengeCache, uint(cfg.Server.Port))
// Create listener for http and start listening
go func() {
log.Info().Msgf("Start HTTP server listening on %s", listeningHTTPAddress)
err := http.ListenAndServe(listeningHTTPAddress, httpHandler)
if err != nil {
log.Error().Err(err).Msg("Couldn't start HTTP server")
}
}()
}
if ctx.IsSet("enable-profiling") {
StartProfilingServer(ctx.String("profiling-address"))
}
// Create ssl handler based on settings
sslHandler := handler.Handler(cfg.Server, giteaClient, canonicalDomainCache, redirectsCache)
// Start the ssl listener
log.Info().Msgf("Start SSL server using TCP listener on %s", listener.Addr())
return http.Serve(listener, sslHandler)
}

View file

@ -8,7 +8,6 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"codeberg.org/codeberg/pages/server/cache" "codeberg.org/codeberg/pages/server/cache"
"codeberg.org/codeberg/pages/server/context"
"codeberg.org/codeberg/pages/server/gitea" "codeberg.org/codeberg/pages/server/gitea"
) )
@ -18,7 +17,7 @@ var canonicalDomainCacheTimeout = 15 * time.Minute
const canonicalDomainConfig = ".domains" const canonicalDomainConfig = ".domains"
// 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 (o *Options) CheckCanonicalDomain(ctx *context.Context, giteaClient *gitea.Client, actualDomain, mainDomainSuffix string, canonicalDomainCache cache.ICache) (domain string, valid bool) { func (o *Options) CheckCanonicalDomain(giteaClient *gitea.Client, actualDomain, mainDomainSuffix string, canonicalDomainCache cache.SetGetKey) (domain string, valid bool) {
// Check if this request is cached. // Check if this request is cached.
if cachedValue, ok := canonicalDomainCache.Get(o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch); ok { if cachedValue, ok := canonicalDomainCache.Get(o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch); ok {
domains := cachedValue.([]string) domains := cachedValue.([]string)
@ -31,7 +30,7 @@ func (o *Options) CheckCanonicalDomain(ctx *context.Context, giteaClient *gitea.
return domains[0], valid return domains[0], valid
} }
body, err := giteaClient.GiteaRawContent(ctx, o.TargetOwner, o.TargetRepo, o.TargetBranch, canonicalDomainConfig) body, err := giteaClient.GiteaRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, canonicalDomainConfig)
if err != nil && !errors.Is(err, gitea.ErrorNotFound) { if err != nil && !errors.Is(err, gitea.ErrorNotFound) {
log.Error().Err(err).Msgf("could not read %s of %s/%s", canonicalDomainConfig, o.TargetOwner, o.TargetRepo) log.Error().Err(err).Msgf("could not read %s of %s/%s", canonicalDomainConfig, o.TargetOwner, o.TargetRepo)
} }
@ -42,7 +41,7 @@ func (o *Options) CheckCanonicalDomain(ctx *context.Context, giteaClient *gitea.
domain = strings.TrimSpace(domain) domain = strings.TrimSpace(domain)
domain = strings.TrimPrefix(domain, "http://") domain = strings.TrimPrefix(domain, "http://")
domain = strings.TrimPrefix(domain, "https://") domain = strings.TrimPrefix(domain, "https://")
if domain != "" && !strings.HasPrefix(domain, "#") && !strings.ContainsAny(domain, "\t /") && strings.ContainsRune(domain, '.') { if len(domain) > 0 && !strings.HasPrefix(domain, "#") && !strings.ContainsAny(domain, "\t /") && strings.ContainsRune(domain, '.') {
domains = append(domains, domain) domains = append(domains, domain)
} }
if domain == actualDomain { if domain == actualDomain {

View file

@ -24,8 +24,5 @@ func (o *Options) setHeader(ctx *context.Context, header http.Header) {
} else { } else {
ctx.RespWriter.Header().Set(gitea.ContentTypeHeader, mime) ctx.RespWriter.Header().Set(gitea.ContentTypeHeader, mime)
} }
if encoding := header.Get(gitea.ContentEncodingHeader); encoding != "" && encoding != "identity" { ctx.RespWriter.Header().Set(headerLastModified, o.BranchTimestamp.In(time.UTC).Format(time.RFC1123))
ctx.RespWriter.Header().Set(gitea.ContentEncodingHeader, encoding)
}
ctx.RespWriter.Header().Set(headerLastModified, o.BranchTimestamp.In(time.UTC).Format(http.TimeFormat))
} }

View file

@ -17,34 +17,13 @@ type Redirect struct {
StatusCode int StatusCode int
} }
// rewriteURL returns the destination URL and true if r matches reqURL.
func (r *Redirect) rewriteURL(reqURL string) (dstURL string, ok bool) {
// check if from url matches request url
if strings.TrimSuffix(r.From, "/") == strings.TrimSuffix(reqURL, "/") {
return r.To, true
}
// handle wildcard redirects
if strings.HasSuffix(r.From, "/*") {
trimmedFromURL := strings.TrimSuffix(r.From, "/*")
if reqURL == trimmedFromURL || strings.HasPrefix(reqURL, trimmedFromURL+"/") {
if strings.Contains(r.To, ":splat") {
matched := strings.TrimPrefix(reqURL, trimmedFromURL)
matched = strings.TrimPrefix(matched, "/")
return strings.ReplaceAll(r.To, ":splat", matched), true
}
return r.To, true
}
}
return "", false
}
// redirectsCacheTimeout specifies the timeout for the redirects cache. // redirectsCacheTimeout specifies the timeout for the redirects cache.
var redirectsCacheTimeout = 10 * time.Minute var redirectsCacheTimeout = 10 * time.Minute
const redirectsConfig = "_redirects" const redirectsConfig = "_redirects"
// getRedirects returns redirects specified in the _redirects file. // getRedirects returns redirects specified in the _redirects file.
func (o *Options) getRedirects(ctx *context.Context, giteaClient *gitea.Client, redirectsCache cache.ICache) []Redirect { func (o *Options) getRedirects(giteaClient *gitea.Client, redirectsCache cache.SetGetKey) []Redirect {
var redirects []Redirect var redirects []Redirect
cacheKey := o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch cacheKey := o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch
@ -53,7 +32,7 @@ func (o *Options) getRedirects(ctx *context.Context, giteaClient *gitea.Client,
redirects = cachedValue.([]Redirect) redirects = cachedValue.([]Redirect)
} else { } else {
// Get _redirects file and parse // Get _redirects file and parse
body, err := giteaClient.GiteaRawContent(ctx, o.TargetOwner, o.TargetRepo, o.TargetBranch, redirectsConfig) body, err := giteaClient.GiteaRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, redirectsConfig)
if err == nil { if err == nil {
for _, line := range strings.Split(string(body), "\n") { for _, line := range strings.Split(string(body), "\n") {
redirectArr := strings.Fields(line) redirectArr := strings.Fields(line)
@ -84,23 +63,53 @@ func (o *Options) getRedirects(ctx *context.Context, giteaClient *gitea.Client,
return redirects return redirects
} }
func (o *Options) matchRedirects(ctx *context.Context, giteaClient *gitea.Client, redirects []Redirect, redirectsCache cache.ICache) (final bool) { func (o *Options) matchRedirects(ctx *context.Context, giteaClient *gitea.Client, redirects []Redirect, redirectsCache cache.SetGetKey) (final bool) {
reqURL := ctx.Req.RequestURI if len(redirects) > 0 {
// remove repo and branch from request url
reqURL = strings.TrimPrefix(reqURL, "/"+o.TargetRepo)
reqURL = strings.TrimPrefix(reqURL, "/@"+o.TargetBranch)
for _, redirect := range redirects { for _, redirect := range redirects {
if dstURL, ok := redirect.rewriteURL(reqURL); ok { reqUrl := ctx.Req.RequestURI
if o.TargetPath == dstURL { // recursion base case, rewrite directly when paths are the same // remove repo and branch from request url
return true reqUrl = strings.TrimPrefix(reqUrl, "/"+o.TargetRepo)
} else if redirect.StatusCode == 200 { // do rewrite if status code is 200 reqUrl = strings.TrimPrefix(reqUrl, "/@"+o.TargetBranch)
o.TargetPath = dstURL
// check if from url matches request url
if strings.TrimSuffix(redirect.From, "/") == strings.TrimSuffix(reqUrl, "/") {
// do rewrite if status code is 200
if redirect.StatusCode == 200 {
o.TargetPath = redirect.To
o.Upstream(ctx, giteaClient, redirectsCache) o.Upstream(ctx, giteaClient, redirectsCache)
} else {
ctx.Redirect(dstURL, redirect.StatusCode)
}
return true return true
} else {
ctx.Redirect(redirect.To, redirect.StatusCode)
return true
}
}
// handle wildcard redirects
trimmedFromUrl := strings.TrimSuffix(redirect.From, "/*")
if strings.HasSuffix(redirect.From, "/*") && strings.HasPrefix(reqUrl, trimmedFromUrl) {
if strings.Contains(redirect.To, ":splat") {
splatUrl := strings.ReplaceAll(redirect.To, ":splat", strings.TrimPrefix(reqUrl, trimmedFromUrl))
// do rewrite if status code is 200
if redirect.StatusCode == 200 {
o.TargetPath = splatUrl
o.Upstream(ctx, giteaClient, redirectsCache)
return true
} else {
ctx.Redirect(splatUrl, redirect.StatusCode)
return true
}
} else {
// do rewrite if status code is 200
if redirect.StatusCode == 200 {
o.TargetPath = redirect.To
o.Upstream(ctx, giteaClient, redirectsCache)
return true
} else {
ctx.Redirect(redirect.To, redirect.StatusCode)
return true
}
}
}
} }
} }

View file

@ -1,36 +0,0 @@
package upstream
import (
"testing"
)
func TestRedirect_rewriteURL(t *testing.T) {
for _, tc := range []struct {
redirect Redirect
reqURL string
wantDstURL string
wantOk bool
}{
{Redirect{"/", "/dst", 200}, "/", "/dst", true},
{Redirect{"/", "/dst", 200}, "/foo", "", false},
{Redirect{"/src", "/dst", 200}, "/src", "/dst", true},
{Redirect{"/src", "/dst", 200}, "/foo", "", false},
{Redirect{"/src", "/dst", 200}, "/src/foo", "", false},
{Redirect{"/*", "/dst", 200}, "/", "/dst", true},
{Redirect{"/*", "/dst", 200}, "/src", "/dst", true},
{Redirect{"/src/*", "/dst/:splat", 200}, "/src", "/dst/", true},
{Redirect{"/src/*", "/dst/:splat", 200}, "/src/", "/dst/", true},
{Redirect{"/src/*", "/dst/:splat", 200}, "/src/foo", "/dst/foo", true},
{Redirect{"/src/*", "/dst/:splat", 200}, "/src/foo/bar", "/dst/foo/bar", true},
{Redirect{"/src/*", "/dst/:splatsuffix", 200}, "/src/foo", "/dst/foosuffix", true},
{Redirect{"/src/*", "/dst:splat", 200}, "/src/foo", "/dstfoo", true},
{Redirect{"/src/*", "/dst", 200}, "/srcfoo", "", false},
// This is the example from FEATURES.md:
{Redirect{"/articles/*", "/posts/:splat", 302}, "/articles/2022/10/12/post-1/", "/posts/2022/10/12/post-1/", true},
} {
if dstURL, ok := tc.redirect.rewriteURL(tc.reqURL); dstURL != tc.wantDstURL || ok != tc.wantOk {
t.Errorf("%#v.rewriteURL(%q) = %q, %v; want %q, %v",
tc.redirect, tc.reqURL, dstURL, ok, tc.wantDstURL, tc.wantOk)
}
}
}

View file

@ -1,13 +1,10 @@
package upstream package upstream
import ( import (
"cmp"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"slices"
"strconv"
"strings" "strings"
"time" "time"
@ -22,8 +19,6 @@ import (
const ( const (
headerLastModified = "Last-Modified" headerLastModified = "Last-Modified"
headerIfModifiedSince = "If-Modified-Since" headerIfModifiedSince = "If-Modified-Since"
headerAcceptEncoding = "Accept-Encoding"
headerContentEncoding = "Content-Encoding"
rawMime = "text/plain; charset=utf-8" rawMime = "text/plain; charset=utf-8"
) )
@ -57,80 +52,12 @@ type Options struct {
ServeRaw bool ServeRaw bool
} }
// allowed encodings
var allowedEncodings = map[string]string{
"gzip": ".gz",
"br": ".br",
"zstd": ".zst",
"identity": "",
}
// parses Accept-Encoding header into a list of acceptable encodings
func AcceptEncodings(header string) []string {
log.Trace().Msgf("got accept-encoding: %s", header)
encodings := []string{}
globQuality := 0.0
qualities := make(map[string]float64)
for _, encoding := range strings.Split(header, ",") {
name, quality_str, has_quality := strings.Cut(encoding, ";q=")
quality := 1.0
if has_quality {
var err error
quality, err = strconv.ParseFloat(quality_str, 64)
if err != nil || quality < 0 {
continue
}
}
name = strings.TrimSpace(name)
if name == "*" {
globQuality = quality
} else {
_, allowed := allowedEncodings[name]
if allowed {
qualities[name] = quality
if quality > 0 {
encodings = append(encodings, name)
}
}
}
}
if globQuality > 0 {
for encoding := range allowedEncodings {
_, exists := qualities[encoding]
if !exists {
encodings = append(encodings, encoding)
qualities[encoding] = globQuality
}
}
} else {
_, exists := qualities["identity"]
if !exists {
encodings = append(encodings, "identity")
qualities["identity"] = -1
}
}
slices.SortStableFunc(encodings, func(x, y string) int {
// sort in reverse order; big quality comes first
return cmp.Compare(qualities[y], qualities[x])
})
log.Trace().Msgf("decided encoding order: %v", encodings)
return encodings
}
// 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 *context.Context, giteaClient *gitea.Client, redirectsCache cache.ICache) bool { func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redirectsCache cache.SetGetKey) bool {
log := log.With().Str("ReqId", ctx.ReqId).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()
log.Debug().Msg("Start")
if o.TargetOwner == "" || o.TargetRepo == "" { if o.TargetOwner == "" || o.TargetRepo == "" {
html.ReturnErrorPage(ctx, "forge client: either repo owner or name info is missing", http.StatusBadRequest) html.ReturnErrorPage(ctx, "gitea client: either repo owner or name info is missing", http.StatusBadRequest)
return true return true
} }
@ -168,52 +95,22 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redi
log.Debug().Msg("Preparing") log.Debug().Msg("Preparing")
var reader io.ReadCloser reader, header, statusCode, err := giteaClient.ServeRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath)
var header http.Header
var statusCode int
var err error
// pick first non-404 response for encoding, *only* if not root
if o.TargetPath == "" || strings.HasSuffix(o.TargetPath, "/") {
err = gitea.ErrorNotFound
} else {
for _, encoding := range AcceptEncodings(ctx.Req.Header.Get(headerAcceptEncoding)) {
log.Trace().Msgf("try %s encoding", encoding)
// add extension for encoding
path := o.TargetPath + allowedEncodings[encoding]
reader, header, statusCode, err = giteaClient.ServeRawContent(ctx, o.TargetOwner, o.TargetRepo, o.TargetBranch, path, true)
if statusCode == http.StatusNotFound {
continue
}
if err != nil {
break
}
log.Debug().Msgf("using %s encoding", encoding)
if encoding != "identity" {
header.Set(headerContentEncoding, encoding)
}
break
}
if reader != nil { if reader != nil {
defer reader.Close() defer reader.Close()
} }
}
log.Debug().Msg("Aquisting") log.Debug().Msg("Aquisting")
// Handle not found error // Handle not found error
if err != nil && errors.Is(err, gitea.ErrorNotFound) { if err != nil && errors.Is(err, gitea.ErrorNotFound) {
log.Debug().Msg("Handling not found error")
// Get and match redirects // Get and match redirects
redirects := o.getRedirects(ctx, giteaClient, redirectsCache) redirects := o.getRedirects(giteaClient, redirectsCache)
if o.matchRedirects(ctx, giteaClient, redirects, redirectsCache) { if o.matchRedirects(ctx, giteaClient, redirects, redirectsCache) {
log.Trace().Msg("redirect")
return true return true
} }
if o.TryIndexPages { if o.TryIndexPages {
log.Trace().Msg("try index page")
// copy the o struct & try if an index page exists // copy the o struct & try if an index page exists
optionsForIndexPages := *o optionsForIndexPages := *o
optionsForIndexPages.TryIndexPages = false optionsForIndexPages.TryIndexPages = false
@ -224,7 +121,6 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redi
return true return true
} }
} }
log.Trace().Msg("try html file with path name")
// compatibility fix for GitHub Pages (/example → /example.html) // compatibility fix for GitHub Pages (/example → /example.html)
optionsForIndexPages.appendTrailingSlash = false optionsForIndexPages.appendTrailingSlash = false
optionsForIndexPages.redirectIfExists = strings.TrimSuffix(ctx.Path(), "/") + ".html" optionsForIndexPages.redirectIfExists = strings.TrimSuffix(ctx.Path(), "/") + ".html"
@ -234,11 +130,8 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redi
} }
} }
log.Debug().Msg("not found")
ctx.StatusCode = http.StatusNotFound ctx.StatusCode = http.StatusNotFound
if o.TryIndexPages { if o.TryIndexPages {
log.Trace().Msg("try not found page")
// copy the o struct & try if a not found page exists // copy the o struct & try if a not found page exists
optionsForNotFoundPages := *o optionsForNotFoundPages := *o
optionsForNotFoundPages.TryIndexPages = false optionsForNotFoundPages.TryIndexPages = false
@ -249,7 +142,6 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redi
return true return true
} }
} }
log.Trace().Msg("not found page missing")
} }
return false return false
@ -261,16 +153,16 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redi
var msg string var msg string
if err != nil { if err != nil {
msg = "forge client: returned unexpected error" msg = "gitea client: returned unexpected error"
log.Error().Err(err).Msg(msg) log.Error().Err(err).Msg(msg)
msg = fmt.Sprintf("%s: '%v'", msg, err) msg = fmt.Sprintf("%s: '%v'", msg, err)
} }
if reader == nil { if reader == nil {
msg = "forge client: returned no reader" msg = "gitea client: returned no reader"
log.Error().Msg(msg) log.Error().Msg(msg)
} }
if statusCode != http.StatusOK { if statusCode != http.StatusOK {
msg = fmt.Sprintf("forge client: couldn't fetch contents: <code>%d - %s</code>", statusCode, http.StatusText(statusCode)) msg = fmt.Sprintf("gitea client: couldn't fetch contents: <code>%d - %s</code>", statusCode, http.StatusText(statusCode))
log.Error().Msg(msg) log.Error().Msg(msg)
} }
@ -281,12 +173,10 @@ func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redi
// Append trailing slash if missing (for index files), and redirect to fix filenames in general // Append trailing slash if missing (for index files), and redirect to fix filenames in general
// o.appendTrailingSlash is only true when looking for index pages // o.appendTrailingSlash is only true when looking for index pages
if o.appendTrailingSlash && !strings.HasSuffix(ctx.Path(), "/") { if o.appendTrailingSlash && !strings.HasSuffix(ctx.Path(), "/") {
log.Trace().Msg("append trailing slash and redirect")
ctx.Redirect(ctx.Path()+"/", http.StatusTemporaryRedirect) ctx.Redirect(ctx.Path()+"/", http.StatusTemporaryRedirect)
return true return true
} }
if strings.HasSuffix(ctx.Path(), "/index.html") && !o.ServeRaw { if strings.HasSuffix(ctx.Path(), "/index.html") && !o.ServeRaw {
log.Trace().Msg("remove index.html from path and redirect")
ctx.Redirect(strings.TrimSuffix(ctx.Path(), "index.html"), http.StatusTemporaryRedirect) ctx.Redirect(strings.TrimSuffix(ctx.Path(), "index.html"), http.StatusTemporaryRedirect)
return true return true
} }