From 50a258ea590e04cafd7ab485d6e16d97e2ad4a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CxHuPo=E2=80=9D?= <7513325+vrocwang@users.noreply.github.com> Date: Thu, 12 Sep 2024 18:35:01 +0800 Subject: [PATCH] fork from codeberg.org --- .ecrc | 9 + .editorconfig | 17 + .env-dev | 11 + .gitignore | 10 + .golangci.yml | 34 ++ .prettierrc.json | 8 + .yamllint.yaml | 19 + Dockerfile | 36 ++ FEATURES.md | 51 +++ Justfile | 51 +++ LICENSE | 305 +++++++++++++ README.md | 141 ++++++ cli/flags.go | 129 ++++++ cli/setup.go | 17 + config/assets/test_config.toml | 17 + config/config.go | 26 ++ config/setup.go | 103 +++++ config/setup_test.go | 413 ++++++++++++++++++ example_config.toml | 16 + examples/haproxy-sni/.gitignore | 1 + examples/haproxy-sni/README.md | 25 ++ examples/haproxy-sni/dhparam.pem | 8 + examples/haproxy-sni/docker-compose.yml | 21 + examples/haproxy-sni/gitea-www/index.html | 1 + examples/haproxy-sni/gitea.Caddyfile | 3 + .../haproxy-certificates/codeberg.org.pem | 26 ++ .../haproxy-certificates/codeberg.org.pem.key | 28 ++ examples/haproxy-sni/haproxy.cfg | 99 +++++ examples/haproxy-sni/pages-www/index.html | 1 + examples/haproxy-sni/pages.Caddyfile | 4 + examples/haproxy-sni/test.sh | 22 + flake.lock | 73 ++++ flake.nix | 27 ++ go.mod | 40 ++ go.sum | 120 +++++ html/html.go | 54 +++ html/html_test.go | 54 +++ html/templates/error.html | 53 +++ integration/get_test.go | 282 ++++++++++++ integration/main_test.go | 69 +++ main.go | 21 + renovate.json | 27 ++ server/cache/interface.go | 10 + server/cache/memory.go | 7 + server/context/context.go | 62 +++ server/dns/dns.go | 66 +++ server/gitea/cache.go | 127 ++++++ server/gitea/client.go | 330 ++++++++++++++ server/handler/handler.go | 114 +++++ server/handler/handler_custom_domain.go | 73 ++++ server/handler/handler_raw_domain.go | 71 +++ server/handler/handler_sub_domain.go | 156 +++++++ server/handler/handler_test.go | 59 +++ server/handler/hsts.go | 15 + server/handler/try.go | 78 ++++ server/profiling.go | 21 + server/startup.go | 94 ++++ server/upstream/domains.go | 70 +++ server/upstream/header.go | 28 ++ server/upstream/helper.go | 47 ++ server/upstream/redirects.go | 107 +++++ server/upstream/redirects_test.go | 36 ++ server/upstream/upstream.go | 225 ++++++++++ server/upstream/upstream.gov1 | 220 ++++++++++ server/utils/utils.go | 27 ++ server/utils/utils_test.go | 69 +++ server/version/version.go | 3 + 67 files changed, 4587 insertions(+) create mode 100644 .ecrc create mode 100644 .editorconfig create mode 100644 .env-dev create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .prettierrc.json create mode 100644 .yamllint.yaml create mode 100644 Dockerfile create mode 100644 FEATURES.md create mode 100644 Justfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cli/flags.go create mode 100644 cli/setup.go create mode 100644 config/assets/test_config.toml create mode 100644 config/config.go create mode 100644 config/setup.go create mode 100644 config/setup_test.go create mode 100644 example_config.toml create mode 100644 examples/haproxy-sni/.gitignore create mode 100644 examples/haproxy-sni/README.md create mode 100644 examples/haproxy-sni/dhparam.pem create mode 100644 examples/haproxy-sni/docker-compose.yml create mode 100644 examples/haproxy-sni/gitea-www/index.html create mode 100644 examples/haproxy-sni/gitea.Caddyfile create mode 100644 examples/haproxy-sni/haproxy-certificates/codeberg.org.pem create mode 100644 examples/haproxy-sni/haproxy-certificates/codeberg.org.pem.key create mode 100644 examples/haproxy-sni/haproxy.cfg create mode 100644 examples/haproxy-sni/pages-www/index.html create mode 100644 examples/haproxy-sni/pages.Caddyfile create mode 100644 examples/haproxy-sni/test.sh create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 html/html.go create mode 100644 html/html_test.go create mode 100644 html/templates/error.html create mode 100644 integration/get_test.go create mode 100644 integration/main_test.go create mode 100644 main.go create mode 100644 renovate.json create mode 100644 server/cache/interface.go create mode 100644 server/cache/memory.go create mode 100644 server/context/context.go create mode 100644 server/dns/dns.go create mode 100644 server/gitea/cache.go create mode 100644 server/gitea/client.go create mode 100644 server/handler/handler.go create mode 100644 server/handler/handler_custom_domain.go create mode 100644 server/handler/handler_raw_domain.go create mode 100644 server/handler/handler_sub_domain.go create mode 100644 server/handler/handler_test.go create mode 100644 server/handler/hsts.go create mode 100644 server/handler/try.go create mode 100644 server/profiling.go create mode 100644 server/startup.go create mode 100644 server/upstream/domains.go create mode 100644 server/upstream/header.go create mode 100644 server/upstream/helper.go create mode 100644 server/upstream/redirects.go create mode 100644 server/upstream/redirects_test.go create mode 100644 server/upstream/upstream.go create mode 100644 server/upstream/upstream.gov1 create mode 100644 server/utils/utils.go create mode 100644 server/utils/utils_test.go create mode 100644 server/version/version.go diff --git a/.ecrc b/.ecrc new file mode 100644 index 0000000..d9ee788 --- /dev/null +++ b/.ecrc @@ -0,0 +1,9 @@ +{ + "Exclude": [ + ".git", + "go.mod", "go.sum", + "vendor", + "LICENSE", + "_test.go" + ] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..354a828 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +tab_width = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.go] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false +indent_size = 1 diff --git a/.env-dev b/.env-dev new file mode 100644 index 0000000..2286005 --- /dev/null +++ b/.env-dev @@ -0,0 +1,11 @@ +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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3035107 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.idea/ +.cache/ +*.iml +key-database.pogreb/ +acme-account.json +build/ +vendor/ +pages +certs.sqlite +.bash_history diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..488ca09 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +linters-settings: + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - importShadow + - ifElseChain + - hugeParam + +linters: + disable-all: true + enable: + - unconvert + - gocritic + - gofumpt + - bidichk + - errcheck + - gofmt + - goimports + - gosimple + - govet + - ineffassign + - misspell + - staticcheck + - typecheck + - unused + - whitespace + +run: + timeout: 5m diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..aed6467 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "endOfLine": "lf" +} diff --git a/.yamllint.yaml b/.yamllint.yaml new file mode 100644 index 0000000..2b1f87c --- /dev/null +++ b/.yamllint.yaml @@ -0,0 +1,19 @@ +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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6106317 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Set the default Go version as a build argument +ARG XGO="go-1.21.x" + +# Use xgo (a Go cross-compiler tool) as build image +FROM --platform=$BUILDPLATFORM techknowlogick/xgo:${XGO} as build + +# Set the working directory and copy the source code +WORKDIR /go/src/codeberg.org/codeberg/pages +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 + +# 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 \ + /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"] diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..52b90e5 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,51 @@ +# Features + +## Custom domains + +Custom domains can be used by creating a `.domains` file with the domain name, e.g.: + +```text +codeberg.page +``` + +You also have to set some DNS records, see the [Codeberg Documentation](https://docs.codeberg.org/codeberg-pages/using-custom-domain/). + +## Redirects + +Redirects can be created with a `_redirects` file with the following format: + +```text +# Comment +from to [status] +``` + +- Lines starting with `#` are ignored +- `from` - the path to redirect from (Note: repository and branch names are removed from request URLs) +- `to` - the path or URL to redirect to +- `status` - status code to use when redirecting (default 301) + +### Status codes + +- `200` - returns content from specified path (no external URLs) without changing the URL (rewrite) +- `301` - Moved Permanently (Permanent redirect) +- `302` - Found (Temporary redirect) + +### Examples + +#### SPA (single-page application) rewrite + +Redirects all paths to `/index.html` for single-page apps. + +```text +/* /index.html 200 +``` + +#### Splats + +Redirects every path under `/articles` to `/posts` while keeping the path. + +```text +/articles/* /posts/:splat 302 +``` + +Example: `/articles/2022/10/12/post-1/` -> `/posts/2022/10/12/post-1/` diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..428b14e --- /dev/null +++ b/Justfile @@ -0,0 +1,51 @@ +CGO_FLAGS := '-extldflags "-static" -linkmode external' +TAGS := 'sqlite sqlite_unlock_notify netgo' + +dev *FLAGS: + #!/usr/bin/env bash + set -euxo pipefail + set -a # automatically export all variables + source .env-dev + set +a + go run -tags '{{TAGS}}' . {{FLAGS}} + +build: + CGO_ENABLED=1 go build -tags '{{TAGS}}' -ldflags '-s -w {{CGO_FLAGS}}' -v -o build/codeberg-pages-server ./ + +build-tag VERSION: + CGO_ENABLED=1 go build -tags '{{TAGS}}' -ldflags '-s -w -X "codeberg.org/codeberg/pages/server/version.Version={{VERSION}}" {{CGO_FLAGS}}' -v -o build/codeberg-pages-server ./ + +lint: tool-golangci tool-gofumpt + golangci-lint run --timeout 5m --build-tags integration + # TODO: run editorconfig-checker + +fmt: tool-gofumpt + gofumpt -w --extra . + +clean: + go clean ./... + +tool-golangci: + @hash golangci-lint> /dev/null 2>&1; if [ $? -ne 0 ]; then \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ + fi + +tool-gofumpt: + @hash gofumpt> /dev/null 2>&1; if [ $? -ne 0 ]; then \ + go install mvdan.cc/gofumpt@latest; \ + fi + +test: + go test -race -cover -tags '{{TAGS}}' codeberg.org/codeberg/pages/config/ codeberg.org/codeberg/pages/html/ codeberg.org/codeberg/pages/server/... + +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/... + +integration: + go test -race -tags 'integration {{TAGS}}' codeberg.org/codeberg/pages/integration/... + +integration-run TEST: + go test -race -tags 'integration {{TAGS}}' -run "^{{TEST}}$" codeberg.org/codeberg/pages/integration/... + +docker: + docker run --rm -it --user $(id -u) -v $(pwd):/work --workdir /work -e HOME=/work codeberg.org/6543/docker-images/golang_just diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c108cad --- /dev/null +++ b/LICENSE @@ -0,0 +1,305 @@ +European Union Public Licence v. 1.2 + +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the 'EUPL') applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the copyright +notice for the Work: + + + + Licensed under the EUPL + + + +or has expressed by any other means his willingness to license under the EUPL. + + 1. Definitions + + In this Licence, the following terms have the following meaning: + + — 'The Licence': this Licence. + +— 'The Original Work': the work or software distributed or communicated by +the Licensor under this Licence, available as Source Code and also as Executable +Code as the case may be. + +— 'Derivative Works': the works or software that could be created by the Licensee, +based upon the Original Work or modifications thereof. This Licence does not +define the extent of modification or dependence on the Original Work required +in order to classify a work as a Derivative Work; this extent is determined +by copyright law applicable in the country mentioned in Article 15. + + — 'The Work': the Original Work or its Derivative Works. + +— 'The Source Code': the human-readable form of the Work which is the most +convenient for people to study and modify. + +— 'The Executable Code': any code which has generally been compiled and which +is meant to be interpreted by a computer as a program. + +— 'The Licensor': the natural or legal person that distributes or communicates +the Work under the Licence. + +— 'Contributor(s)': any natural or legal person who modifies the Work under +the Licence, or otherwise contributes to the creation of a Derivative Work. + +— 'The Licensee' or 'You': any natural or legal person who makes any usage +of the Work under the terms of the Licence. + +— 'Distribution' or 'Communication': any act of selling, giving, lending, +renting, distributing, communicating, transmitting, or otherwise making available, +online or offline, copies of the Work or providing access to its essential +functionalities at the disposal of any other natural or legal person. + + 2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, sublicensable +licence to do the following, for the duration of copyright vested in the Original +Work: + + — use the Work in any circumstance and for all usage, + + — reproduce the Work, + + — modify the Work, and make Derivative Works based upon the Work, + +— communicate to the public, including the right to make available or display +the Work or copies thereof to the public and perform publicly, as the case +may be, the Work, + + — distribute the Work or copies thereof, + + — lend and rent the Work or copies thereof, + + — sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether +now known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights +to any patents held by the Licensor, to the extent necessary to make use of +the rights granted on the Work under this Licence. + + 3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as Executable +Code. If the Work is provided as Executable Code, the Licensor provides in +addition a machine-readable copy of the Source Code of the Work along with +each copy of the Work that the Licensor distributes or indicates, in a notice +following the copyright notice attached to the Work, a repository where the +Source Code is easily and freely accessible for as long as the Licensor continues +to distribute or communicate the Work. + + 4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits +from any exception or limitation to the exclusive rights of the rights owners +in the Work, of the exhaustion of those rights or of other applicable limitations +thereto. + + 5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the disclaimer +of warranties. The Licensee must include a copy of such notices and a copy +of the Licence with every copy of the Work he/she distributes or communicates. +The Licensee must cause any Derivative Work to carry prominent notices stating +that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will +be done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version +of the Licence — for example by communicating 'EUPL v. 1.2 only'. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions +on the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed +under a Compatible Licence, this Distribution or Communication can be done +under the terms of this Compatible Licence. For the sake of this clause, 'Compatible +Licence' refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the +Work, the Licensee will provide a machine-readable copy of the Source Code +or indicate a repository where this Source will be easily and freely available +for as long as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade +names, trademarks, service marks, or names of the Licensor, except as required +for reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + + 6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has +the power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent Contributors +grant You a licence to their contributions to the Work, under the terms of +this Licence. + + 7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects +or 'bugs' inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an 'as is' +basis and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other +than copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + + 8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the +use of the Work, including without limitation, damages for loss of goodwill, +work stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws +as far such laws apply to the Work. + + 9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor +by the fact You have accepted any warranty or additional liability. + + 10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon 'I agree' +placed under the bottom of a window displaying the text of this Licence or +by affirming consent in any other similar way, in accordance with the rules +of applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and conditions +by exercising any rights granted to You by Article 2 of this Licence, such +as the use of the Work, the creation by You of a Derivative Work or the Distribution +or Communication by You of the Work or copies thereof. + + 11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) +must at least provide to the public the information requested by the applicable +law regarding the Licensor, the Licence and the way it may be accessible, +concluded, stored and reproduced by the Licensee. + + 12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically +upon any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has received +the Work from the Licensee under the Licence, provided such persons remain +in full compliance with the Licence. + + 13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as +a whole. Such provision will be construed or reformed so as necessary to make +it valid and enforceable. + +The European Commission may publish other linguistic versions or new versions +of this Licence or updated versions of the Appendix, so far this is required +and reasonable, without reducing the scope of the rights granted by the Licence. +New versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version +of their choice. + + 14. Jurisdiction + + Without prejudice to specific agreement between parties, + +— any litigation resulting from the interpretation of this License, arising +between the European Union institutions, bodies, offices or agencies, as a +Licensor, and any Licensee, will be subject to the jurisdiction of the Court +of Justice of the European Union, as laid down in article 272 of the Treaty +on the Functioning of the European Union, + +— any litigation arising between other parties and resulting from the interpretation +of this License, will be subject to the exclusive jurisdiction of the competent +court where the Licensor resides or conducts its primary business. + + 15. Applicable Law + + Without prejudice to specific agreement between parties, + +— this Licence shall be governed by the law of the European Union Member State +where the Licensor has his seat, resides or has his registered office, + +— this licence shall be governed by Belgian law if the Licensor has no seat, +residence or registered office inside a European Union Member State. + +Appendix + +'Compatible Licences' according to Article 5 EUPL are: + + — GNU General Public License (GPL) v. 2, v. 3 + + — GNU Affero General Public License (AGPL) v. 3 + + — Open Software License (OSL) v. 2.1, v. 3.0 + + — Eclipse Public License (EPL) v. 1.0 + + — CeCILL v. 2.0, v. 2.1 + + — Mozilla Public Licence (MPL) v. 2 + + — GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 + +— Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for +works other than software + + — European Union Public Licence (EUPL) v. 1.1, v. 1.2 + +— Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong Reciprocity +(LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the +above licences without producing a new version of the EUPL, as long as they +provide the rights granted in Article 2 of this Licence and protect the covered +Source Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of +a new EUPL version. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0051a9e --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# Codeberg Pages + +[![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) + + + + +Gitea lacks the ability to host static pages from Git. +The Codeberg Pages Server addresses this lack by implementing a standalone service +that connects to Gitea via API. +It is suitable to be deployed by other Gitea instances, too, to offer static pages hosting to their users. + +**End user documentation** can mainly be found at the [Wiki](https://codeberg.org/Codeberg/pages-server/wiki/Overview) +and the [Codeberg Documentation](https://docs.codeberg.org/codeberg-pages/). + + Get It On Codeberg + +## Quickstart + +This is the new Codeberg Pages server, a solution for serving static pages from Gitea repositories. +Mapping custom domains is not static anymore, but can be done with DNS: + +1. add a `.domains` text file to your repository, containing the allowed domains, separated by new lines. The + first line will be the canonical domain/URL; all other occurrences will be redirected to it. + +2. add a CNAME entry to your domain, pointing to `[[{branch}.]{repo}.]{owner}.codeberg.page` (repo defaults to + "pages", "branch" defaults to the default branch if "repo" is "pages", or to "pages" if "repo" is something else. + If the branch name contains slash characters, you need to replace "/" in the branch name to "~"): + `www.example.org. IN CNAME main.pages.example.codeberg.page.` + +3. if a CNAME is set for "www.example.org", you can redirect there from the naked domain by adding an ALIAS record + for "example.org" (if your provider allows ALIAS or similar records, otherwise use A/AAAA), together with a TXT + record that points to your repo (just like the CNAME record): + `example.org IN ALIAS codeberg.page.` + `example.org IN TXT main.pages.example.codeberg.page.` + +Certificates are generated, updated and cleaned up automatically via Let's Encrypt through a TLS challenge. + +## Chat for admins & devs + +[matrix: #gitea-pages-server:matrix.org](https://matrix.to/#/#gitea-pages-server:matrix.org) + +## Deployment + +**Warning: Some Caveats Apply** + +> Currently, the deployment requires you to have some knowledge of system administration as well as understanding and building code, +> so you can eventually edit non-configurable and codeberg-specific settings. +> In the future, we'll try to reduce these and make hosting Codeberg Pages as easy as setting up Gitea. +> If you consider using Pages in practice, please consider contacting us first, +> we'll then try to share some basic steps and document the current usage for admins +> (might be changing in the current state). + +Deploying the software itself is very easy. You can grab a current release binary or build yourself, +configure the environment as described below, and you are done. + +The hard part is about adding **custom domain support** if you intend to use it. +SSL certificates (request + renewal) is automatically handled by the Pages Server, +but if you want to run it on a shared IP address (and not a standalone), +you'll need to configure your reverse proxy not to terminate the TLS connections, +but forward the requests on the IP level to the Pages Server. + +You can check out a proof of concept in the `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). + +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 + +- `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: ): set this to 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 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. +- `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. +- `RAW_DOMAIN` (default: `raw.codeberg.page`): domain for raw resources (must be subdomain of `PAGES_DOMAIN`). + +## Contributing to the development + +The Codeberg team is very open to your contribution. +Since we are working nicely in a team, it might be hard at times to get started +(still check out the issues, we always aim to have some things to get you started). + +If you have any questions, want to work on a feature or could imagine collaborating with us for some time, +feel free to ping us in an issue or in a general [Matrix chat room](#chat-for-admins--devs). + +You can also contact the maintainer(s) of this project: + +- [crapStone](https://codeberg.org/crapStone) [(Matrix)](https://matrix.to/#/@crapstone:obermui.de) + +Previous maintainers: + +- [momar](https://codeberg.org/momar) [(Matrix)](https://matrix.to/#/@moritz:wuks.space) +- [6543](https://codeberg.org/6543) [(Matrix)](https://matrix.to/#/@marddl:obermui.de) + +### First steps + +The code of this repository is split in several modules. +The [Architecture is explained](https://codeberg.org/Codeberg/pages-server/wiki/Architecture) in the wiki. + +The `cmd` folder holds the data necessary for interacting with the service via the cli. +The heart of the software lives in the `server` folder and is split in several modules. + +Again: Feel free to get in touch with us for any questions that might arise. +Thank you very much. + +### Test Server + +Make sure you have [golang](https://go.dev) v1.21 or newer and [just](https://just.systems/man/en/) installed. + +run `just dev` +now these pages should work: + +- +- +- +- + +### 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: diff --git a/cli/flags.go b/cli/flags.go new file mode 100644 index 0000000..06b7725 --- /dev/null +++ b/cli/flags.go @@ -0,0 +1,129 @@ +package cli + +import ( + "github.com/urfave/cli/v2" +) + +var ( + ServerFlags = []cli.Flag{ + // ############# + // ### Forge ### + // ############# + // ForgeRoot specifies the root URL of the Forge instance, without a trailing slash. + &cli.StringFlag{ + Name: "forge-root", + Aliases: []string{"gitea-root"}, + Usage: "specifies the root URL of the Forgejo/Gitea instance, without a trailing slash.", + EnvVars: []string{"FORGE_ROOT", "GITEA_ROOT"}, + }, + // ForgeApiToken specifies an api token for the Forge instance + &cli.StringFlag{ + Name: "forge-api-token", + Aliases: []string{"gitea-api-token"}, + Usage: "specifies an api token for the Forgejo/Gitea instance", + EnvVars: []string{"FORGE_API_TOKEN", "GITEA_API_TOKEN"}, + }, + &cli.BoolFlag{ + Name: "enable-lfs-support", + Usage: "enable lfs support, gitea must be version v1.17.0 or higher", + EnvVars: []string{"ENABLE_LFS_SUPPORT"}, + Value: false, + }, + &cli.BoolFlag{ + Name: "enable-symlink-support", + Usage: "follow symlinks if enabled, gitea must be version v1.18.0 or higher", + EnvVars: []string{"ENABLE_SYMLINK_SUPPORT"}, + Value: false, + }, + &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"}, + }, + + // ########################### + // ### Page Server Domains ### + // ########################### + // MainDomainSuffix specifies the main domain (starting with a dot) for which subdomains shall be served as static + // pages, or used for comparison in CNAME lookups. Static pages can be accessed through + // http://{owner}.{MainDomain}[/{repo}], with repo defaulting to "pages". + &cli.StringFlag{ + Name: "pages-domain", + Usage: "specifies the main domain (starting with a dot) for which subdomains shall be served as static pages", + EnvVars: []string{"PAGES_DOMAIN"}, + }, + // RawDomain specifies the domain from which raw repository content shall be served in the following format: + // http://{RawDomain}/{owner}/{repo}[/{branch|tag|commit}/{version}]/{filepath...} + // (set to []byte(nil) to disable raw content hosting) + &cli.StringFlag{ + Name: "raw-domain", + Usage: "specifies the domain from which raw repository content shall be served, not set disable raw content hosting", + EnvVars: []string{"RAW_DOMAIN"}, + }, + + // ######################### + // ### Page Server Setup ### + // ######################### + &cli.StringFlag{ + Name: "host", + Usage: "specifies host of listening address", + EnvVars: []string{"HOST"}, + Value: "[::]", + }, + &cli.UintFlag{ + Name: "port", + Usage: "specifies the http port to listen to ssl requests", + EnvVars: []string{"PORT", "HTTP_PORT"}, + Value: 8080, + }, + // 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{ + Name: "log-level", + Value: "warn", + Usage: "specify at which log level should be logged. Possible options: info, warn, error, fatal", + EnvVars: []string{"LOG_LEVEL"}, + }, + &cli.StringFlag{ + Name: "config-file", + Usage: "specify the location of the config file", + Aliases: []string{"config"}, + EnvVars: []string{"CONFIG_FILE"}, + }, + + &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", + }, + } +) diff --git a/cli/setup.go b/cli/setup.go new file mode 100644 index 0000000..7dbc757 --- /dev/null +++ b/cli/setup.go @@ -0,0 +1,17 @@ +package cli + +import ( + "github.com/urfave/cli/v2" + + "pages-server/server/version" +) + +func CreatePagesApp() *cli.App { + app := cli.NewApp() + app.Name = "pages-server" + app.Version = version.Version + app.Usage = "pages server" + app.Flags = ServerFlags + + return app +} diff --git a/config/assets/test_config.toml b/config/assets/test_config.toml new file mode 100644 index 0000000..fa70a57 --- /dev/null +++ b/config/assets/test_config.toml @@ -0,0 +1,17 @@ +logLevel = 'trace' + +[server] +host = '127.0.0.1' +port = 443 +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"] diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d05ba84 --- /dev/null +++ b/config/config.go @@ -0,0 +1,26 @@ +package config + +type Config struct { + LogLevel string `default:"warn"` + Server ServerConfig + Forge ForgeConfig +} + +type ServerConfig struct { + Host string `default:"127.0.0.1"` + Port uint16 `default:"8080"` + 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 +} diff --git a/config/setup.go b/config/setup.go new file mode 100644 index 0000000..5cb0abe --- /dev/null +++ b/config/setup.go @@ -0,0 +1,103 @@ +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) +} + +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("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") + } +} diff --git a/config/setup_test.go b/config/setup_test.go new file mode 100644 index 0000000..19b1ade --- /dev/null +++ b/config/setup_test.go @@ -0,0 +1,413 @@ +package config + +import ( + "context" + "os" + "testing" + + "github.com/pelletier/go-toml/v2" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" + + cmd "pages-server/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.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", + "--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, + + 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"}, + }, + } + + MergeConfig(ctx, cfg) + + expectedConfig := &Config{ + LogLevel: "changed", + Server: ServerConfig{ + Host: "changed", + Port: 8443, + 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"}, + }, + } + + 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", + // 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 TestMergeServerConfigShouldReplaceAllExistingValuesGivenAllArgsExist(t *testing.T) { + for range []uint8{0, 1} { + runApp( + t, + func(ctx *cli.Context) error { + cfg := &ServerConfig{ + Host: "original", + Port: 8080, + MainDomain: "original", + RawDomain: "original", + AllowedCorsDomains: []string{"original"}, + BlacklistedPaths: []string{"original"}, + } + + mergeServerConfig(ctx, cfg) + + expectedConfig := &ServerConfig{ + Host: "changed", + Port: 8080, + 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", + }, + ) + } +} + +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", "8080"}, callback: func(sc *ServerConfig) { sc.Port = 8080 }}, + {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, + 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", + }, + ) +} diff --git a/example_config.toml b/example_config.toml new file mode 100644 index 0000000..6fc06f0 --- /dev/null +++ b/example_config.toml @@ -0,0 +1,16 @@ +logLevel = 'debug' + +[server] +host = '127.0.0.1' +port = 8080 +mainDomain = 'codeberg.page' +rawDomain = 'raw.codeberg.page' +pagesBranches = ["pages"] +allowedCorsDomains = [] +blacklistedPaths = [] + +[forge] +root = 'https://codeberg.org' +token = 'ASDF1234' +lfsEnabled = true +followSymlinks = true \ No newline at end of file diff --git a/examples/haproxy-sni/.gitignore b/examples/haproxy-sni/.gitignore new file mode 100644 index 0000000..2232829 --- /dev/null +++ b/examples/haproxy-sni/.gitignore @@ -0,0 +1 @@ +*.dump diff --git a/examples/haproxy-sni/README.md b/examples/haproxy-sni/README.md new file mode 100644 index 0000000..8165d6c --- /dev/null +++ b/examples/haproxy-sni/README.md @@ -0,0 +1,25 @@ +# 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). + +## How it works + +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. +3. The `https_termination_backend` passes all requests to a unix socket (using the plain TCP data). +4. The `https_termination_frontend` listens on said unix socket, terminates the HTTPS connections and then chooses a backend based on the Host header. + +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 + +```bash +docker-compose up & +./test.sh +docker-compose down + +# For manual testing: all HTTPS URLs connect to localhost:443 & certificates are not verified. +./test.sh [curl-options...] +``` + +![Screenshot of the test script's output](/attachments/c82d79ea-7586-4d4b-b340-3ad0030185d6) diff --git a/examples/haproxy-sni/dhparam.pem b/examples/haproxy-sni/dhparam.pem new file mode 100644 index 0000000..9b182b7 --- /dev/null +++ b/examples/haproxy-sni/dhparam.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA//////////+t+FRYortKmq/cViAnPTzx2LnFg84tNpWp4TZBFGQz ++8yTnc4kmz75fS/jY2MMddj2gbICrsRhetPfHtXV/WVhJDP1H18GbtCFY2VVPe0a +87VXE15/V8k1mE8McODmi3fipona8+/och3xWKE2rec1MKzKT0g6eXq8CrGCsyT7 +YdEIqUuyyOP7uWrat2DX9GgdT0Kj3jlN9K5W7edjcrsZCwenyO4KbXCeAvzhzffi +7MA0BM0oNC9hkXL+nOmFg/+OTxIy7vKBg8P+OxtMb61zO7X8vC7CIAXFjvGDfRaD +ssbzSibBsu/6iGtCOGEoXJf//////////wIBAg== +-----END DH PARAMETERS----- diff --git a/examples/haproxy-sni/docker-compose.yml b/examples/haproxy-sni/docker-compose.yml new file mode 100644 index 0000000..2cac2ad --- /dev/null +++ b/examples/haproxy-sni/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3' +services: + haproxy: + image: haproxy + ports: ['443:443'] + volumes: + - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + - ./dhparam.pem:/etc/ssl/dhparam.pem:ro + - ./haproxy-certificates:/etc/ssl/private/haproxy:ro + cap_add: + - NET_ADMIN + gitea: + image: caddy + volumes: + - ./gitea-www:/srv:ro + - ./gitea.Caddyfile:/etc/caddy/Caddyfile:ro + pages: + image: caddy + volumes: + - ./pages-www:/srv:ro + - ./pages.Caddyfile:/etc/caddy/Caddyfile:ro diff --git a/examples/haproxy-sni/gitea-www/index.html b/examples/haproxy-sni/gitea-www/index.html new file mode 100644 index 0000000..d092750 --- /dev/null +++ b/examples/haproxy-sni/gitea-www/index.html @@ -0,0 +1 @@ +Hello to Gitea! diff --git a/examples/haproxy-sni/gitea.Caddyfile b/examples/haproxy-sni/gitea.Caddyfile new file mode 100644 index 0000000..e92a157 --- /dev/null +++ b/examples/haproxy-sni/gitea.Caddyfile @@ -0,0 +1,3 @@ +http://codeberg.org + +file_server diff --git a/examples/haproxy-sni/haproxy-certificates/codeberg.org.pem b/examples/haproxy-sni/haproxy-certificates/codeberg.org.pem new file mode 100644 index 0000000..e85b673 --- /dev/null +++ b/examples/haproxy-sni/haproxy-certificates/codeberg.org.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEUDCCArigAwIBAgIRAMq3iwF963VGkzXFpbrpAtkwDQYJKoZIhvcNAQELBQAw +gYkxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEvMC0GA1UECwwmbW9t +YXJAbW9yaXR6LWxhcHRvcCAoTW9yaXR6IE1hcnF1YXJkdCkxNjA0BgNVBAMMLW1r +Y2VydCBtb21hckBtb3JpdHotbGFwdG9wIChNb3JpdHogTWFycXVhcmR0KTAeFw0y +MTA2MDYwOTQ4NDFaFw0yMzA5MDYwOTQ4NDFaMFoxJzAlBgNVBAoTHm1rY2VydCBk +ZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEvMC0GA1UECwwmbW9tYXJAbW9yaXR6LWxh +cHRvcCAoTW9yaXR6IE1hcnF1YXJkdCkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQCrSPSPM6grNZMG4ZKFCVxuXu+qkHdzSR96QUxi00VkIrkGPmyMN7q7 +rUQJto9C9guJio3n7y3Bvr5kBjICjyWQd7GfkVuYgiYiG/O2hy1u1dIMCAB/Zhx1 +F1mvRfn/Q4eZk2GSOUM+kC0xaNsn2827VGLOGFywUhRmu7J9QSQ3x1Pi5BME7eNC +AKup0CbrMrZSzKAEuYujLY0UYRxUrguMnV60wxJDCYE14YDxn9t0g7wQmzyndupk +AMLNJZX5L83RA6vUEuTVYBFcyB0Fu3oBLQ31y5QOZ7WF/QiO5cPicQJI/oyXlHq4 +97BWS/H28kj1H5ZM8+5yhCYDtgj7dERpAgMBAAGjYTBfMA4GA1UdDwEB/wQEAwIF +oDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBSOSXQZqt2gjbTOkE9Q +ddI8SYPqrDAXBgNVHREEEDAOggxjb2RlYmVyZy5vcmcwDQYJKoZIhvcNAQELBQAD +ggGBAJ/57DGqfuOa3aS/nLeAzl8komvyHuoOZi9yDK2Jqr+COxP58zSu8xwhiZfc +TJvIyB9QR7imGiQ7fEKby40q8uxGGx13oY7gQy7PG8hHk2dkfDZuSQacnpPRC3W0 +0dL2CQIog6rw6jJHjxneitkX9FUmOnHIKy7LHya0Sthg36Z0Qw5JA3SCy6OQNepR +R2XzwTZ0KFk6gAuKCto8ENUlU5lV9PM4X3U0cBOIc5LJAPM+cxEDUocFtFqKJPbe +YYlSeB200YhYOdi+x34n9xnQjFu/jVlWF+Y0tMBB1WWq6rZbnuylwWLYQZAo10Co +D3oWsYRlD/ZL7X20ztIy8vRXz33ugnxxf88Q7csWDYb4S325svLfI2EjciIxYmBo +dSJxXRQkadjIoI7gNvzeWBkYSJpQUbaD4nT2xRS8vfuv42/DrIehb8SbTivHmcB3 +OibpWIvDtS1B8thIlzl0edb+8pb6mof7pOBxoZdcBsSAk2/48s+jfRHfD9XcuKnv +hGCdSQ== +-----END CERTIFICATE----- diff --git a/examples/haproxy-sni/haproxy-certificates/codeberg.org.pem.key b/examples/haproxy-sni/haproxy-certificates/codeberg.org.pem.key new file mode 100644 index 0000000..b9c4d61 --- /dev/null +++ b/examples/haproxy-sni/haproxy-certificates/codeberg.org.pem.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrSPSPM6grNZMG +4ZKFCVxuXu+qkHdzSR96QUxi00VkIrkGPmyMN7q7rUQJto9C9guJio3n7y3Bvr5k +BjICjyWQd7GfkVuYgiYiG/O2hy1u1dIMCAB/Zhx1F1mvRfn/Q4eZk2GSOUM+kC0x +aNsn2827VGLOGFywUhRmu7J9QSQ3x1Pi5BME7eNCAKup0CbrMrZSzKAEuYujLY0U +YRxUrguMnV60wxJDCYE14YDxn9t0g7wQmzyndupkAMLNJZX5L83RA6vUEuTVYBFc +yB0Fu3oBLQ31y5QOZ7WF/QiO5cPicQJI/oyXlHq497BWS/H28kj1H5ZM8+5yhCYD +tgj7dERpAgMBAAECggEAAeW+/88cr83aIRtimiKuaXKXyRXsnNRUivAqPnYEsMVJ +s24BmdQMN4QF2u2wzJcZLZ7hT45wvVK1nToMV8bqLZ2F1DSyBRB8B6iznHQG5tFr +kEKObtrcuddWYQCvckp3OBZP4GTN/+Vs+r0koF5o+whGR+4xKKrgGvs9UPHlytBf +0DMzAzWzGPp6qBPw2sUx/fa9r5TqFW+p4SEOZJUqL2/zEZ6KBWbKw5T1e1y2kMEc +cquUQ4avqK/N1nwRNKUnTvW827v0k7HQ2cFdrjIATNlICslOWJQicG5GUOuSBkTC +0FFkSTtHP4qm0BqShjv6NDmzX+3WCVkGOKFOI+zuWQKBgQDBq8yEcvfMJY98KNlR +eKKdJAMJvKdoD65Yv6EG7ZzpeEWHaTGhu71RPgHYkHn8h1T/9WniroSk19+zb4lP +mMsBwxpg5HejWPzIiiJRkRCRA7aZZfvaXfIWryB4kI1tlGHBNN/+SYpG1zdNumtp +Xyb/sQWMMWRZdRgclF8V+NvduwKBgQDiaM59gBROleREduFZE1a0oXtt+CrwrPlz +hclrkYl1FbTA4TdL4JNbj5jCXCR8YakFhxWEmhwq+Dgl1NQY/YjHyG3w2imaeASX +QUsEvAIvNrv1mIELiYCLmUElyX4WL3UhqveOFcZUvR1Z4TTwruPQmXf6BJEBLbWI +f7odmG6yKwKBgQCzpuLjZiZY9+qe2OGmQopNzE8JJDgCPrGS38fGvnnU1N1iXAFP +LvDRwPxDYNnXl84QVR2wygR/SUTYlTlBXdHKw6nfgW89Vlm+yOxGz5MXgeNLbp/u +k0DzK+aqECUxJfh8GclCgANF7XP+pVPn/f0WKKalwld86DLCqBuALUX+6wKBgCUh +gxvZ8Xqh4nnH9VUicsnU4eU7Ge+2roJfopTdnWlyUd6AEQ2EmyYc+rSFYAZ2Db42 +VTUWASCa7LpnmREwI0qAeGdToBcRL8+OibsRClqr409331IBDu/WBnUoAmGpDtCi +tU68C3bCPRoMcR430GzZfm+maBGFaYwlRmSsJxtZAoGADSA3uAZBuWNDPNKUas2k +Z2dXFEPNpViMjQzJ+Ko7lbOBpUUUQfZF2VMSK4lcnhhbmhcMrYzWWmh6uaw78aHY +e3M//BfcVMdxHw7EemGOViNNq3uDIwzvYteoe6fAOA7MaV+WjJaf+smceR4o38fk +U9RTkKpRJIcvEW5bvTI9h4o= +-----END PRIVATE KEY----- diff --git a/examples/haproxy-sni/haproxy.cfg b/examples/haproxy-sni/haproxy.cfg new file mode 100644 index 0000000..c8f3610 --- /dev/null +++ b/examples/haproxy-sni/haproxy.cfg @@ -0,0 +1,99 @@ +##################################### +## Global Configuration & Defaults ## +##################################### + +global + log stderr format iso local7 + + # generated 2021-06-05, Mozilla Guideline v5.6, HAProxy 2.1, OpenSSL 1.1.1d, intermediate configuration + # https://ssl-config.mozilla.org/#server=haproxy&version=2.1&config=intermediate&openssl=1.1.1d&guideline=5.6 + ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets + + ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 + ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets + + # curl https://ssl-config.mozilla.org/ffdhe2048.txt > /path/to/dhparam + ssl-dh-param-file /etc/ssl/dhparam.pem + +defaults + log global + timeout connect 30000 + timeout check 300000 + timeout client 300000 + timeout server 300000 + +############################################################################ +## Frontends: HTTP; HTTPS → HTTPS SNI-based; HTTPS → HTTP(S) header-based ## +############################################################################ + +frontend http_redirect_frontend + # HTTP backend to redirect everything to HTTPS + bind :::80 v4v6 + mode http + http-request redirect scheme https + +frontend https_sni_frontend + # TCP backend to forward to HTTPS backends based on SNI + bind :::443 v4v6 + mode tcp + + # Wait up to 5s for a SNI header & only accept TLS connections + tcp-request inspect-delay 5s + tcp-request content capture req.ssl_sni len 255 + log-format "%ci:%cp -> %[capture.req.hdr(0)] @ %f (%fi:%fp) -> %b (%bi:%bp)" + tcp-request content accept if { req.ssl_hello_type 1 } + + ################################################### + ## Rules: forward to HTTPS(S) header-based rules ## + ################################################### + acl use_http_backend req.ssl_sni -i "codeberg.org" + acl use_http_backend req.ssl_sni -i "join.codeberg.org" + # TODO: use this if no SNI exists + use_backend https_termination_backend if use_http_backend + + ############################ + ## Rules: HTTPS SNI-based ## + ############################ + # use_backend xyz_backend if { req.ssl_sni -i "xyz" } + default_backend pages_backend + +frontend https_termination_frontend + # Terminate TLS for HTTP backends + bind /tmp/haproxy-tls-termination.sock accept-proxy ssl strict-sni alpn h2,http/1.1 crt /etc/ssl/private/haproxy/ + mode http + + # HSTS (63072000 seconds) + http-response set-header Strict-Transport-Security max-age=63072000 + + http-request capture req.hdr(Host) len 255 + log-format "%ci:%cp -> %[capture.req.hdr(0)] @ %f (%fi:%fp) -> %b (%bi:%bp)" + + ################################## + ## Rules: HTTPS(S) header-based ## + ################################## + use_backend gitea_backend if { hdr(host) -i codeberg.org } + +backend https_termination_backend + # Redirect to the terminating HTTPS frontend for all HTTP backends + server https_termination_server /tmp/haproxy-tls-termination.sock send-proxy-v2-ssl-cn + mode tcp + +############################### +## Backends: HTTPS SNI-based ## +############################### + +backend pages_backend + # Pages server is a HTTP backend that uses its own certificates for custom domains + server pages_server pages:443 + mode tcp + +#################################### +## Backends: HTTP(S) header-based ## +#################################### + +backend gitea_backend + server gitea_server gitea:80 + mode http diff --git a/examples/haproxy-sni/pages-www/index.html b/examples/haproxy-sni/pages-www/index.html new file mode 100644 index 0000000..dc24785 --- /dev/null +++ b/examples/haproxy-sni/pages-www/index.html @@ -0,0 +1 @@ +Hello to Pages! diff --git a/examples/haproxy-sni/pages.Caddyfile b/examples/haproxy-sni/pages.Caddyfile new file mode 100644 index 0000000..17adc19 --- /dev/null +++ b/examples/haproxy-sni/pages.Caddyfile @@ -0,0 +1,4 @@ +https://example-page.org + +tls internal +file_server diff --git a/examples/haproxy-sni/test.sh b/examples/haproxy-sni/test.sh new file mode 100644 index 0000000..89e2dfd --- /dev/null +++ b/examples/haproxy-sni/test.sh @@ -0,0 +1,22 @@ +#!/bin/sh +if [ $# -gt 0 ]; then + exec curl -k --resolve '*:443:127.0.0.1' "$@" +fi + +fail() { + echo "[FAIL] $@" + exit 1 +} + +echo "Connecting to Gitea..." +res=$(curl https://codeberg.org -sk --resolve '*:443:127.0.0.1' --trace-ascii gitea.dump | tee /dev/stderr) +echo "$res" | grep -Fx 'Hello to Gitea!' >/dev/null || fail "Gitea didn't answer" +grep '^== Info: issuer: O=mkcert development CA;' gitea.dump || { grep grep '^== Info: issuer:' gitea.dump; fail "Gitea didn't use the correct certificate!"; } + +echo "Connecting to Pages..." +res=$(curl https://example-page.org -sk --resolve '*:443:127.0.0.1' --trace-ascii pages.dump | tee /dev/stderr) +echo "$res" | grep -Fx 'Hello to Pages!' >/dev/null || fail "Pages didn't answer" +grep '^== Info: issuer: CN=Caddy Local Authority\b' pages.dump || { grep '^== Info: issuer:' pages.dump; fail "Pages didn't use the correct certificate!"; } + +echo "All tests succeeded" +rm *.dump diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c74fe33 --- /dev/null +++ b/flake.lock @@ -0,0 +1,73 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1710146030, + "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "type": "github" + }, + "original": { + "id": "flake-utils", + "type": "indirect" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1716715802, + "narHash": "sha256-usk0vE7VlxPX8jOavrtpOqphdfqEQpf9lgedlY/r66c=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2dd4e18cc1c7314e24154331bae07df76eb582f", + "type": "github" + }, + "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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..61f3b55 --- /dev/null +++ b/flake.nix @@ -0,0 +1,27 @@ +{ + 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; [ + gcc + go + gofumpt + golangci-lint + gopls + gotools + go-tools + sqlite-interactive + ]; + }; + }); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..12e2a52 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module pages-server + +go 1.21 + +toolchain go1.21.4 + +require ( + code.gitea.io/sdk/gitea v0.17.1 + github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a + github.com/creasty/defaults v1.7.0 + github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/joho/godotenv v1.4.0 + github.com/microcosm-cc/bluemonday v1.0.26 + github.com/pelletier/go-toml/v2 v2.1.0 + github.com/rs/zerolog v1.27.0 + github.com/stretchr/testify v1.8.4 + github.com/urfave/cli/v2 v2.3.0 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 +) + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/kr/pretty v0.1.0 // indirect + github.com/mattn/go-colorable v0.1.12 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.15.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0ef127 --- /dev/null +++ b/go.sum @@ -0,0 +1,120 @@ +code.gitea.io/sdk/gitea v0.17.1 h1:3jCPOG2ojbl8AcfaUCRYLT5MUcBMFwS0OSK2mA5Zok8= +code.gitea.io/sdk/gitea v0.17.1/go.mod h1:aCnBqhHpoEWA180gMbaCtdX9Pl6BWBAuuP2miadoTNM= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a h1:Cf4CrDeyrIcuIiJZEZJAH5dapqQ6J3OmP/vHPbDjaFA= +github.com/OrlovEvgeny/go-mcache v0.0.0-20200121124330-1a8195b34f3a/go.mod h1:ig6eVXkYn/9dz0Vm8UdLf+E0u1bE6kBSn3n2hqk6jas= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA= +github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= +github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.27.0 h1:1T7qCieN22GVc8S4Q2yuexzBb1EqjbgjSH9RohbMjKs= +github.com/rs/zerolog v1.27.0/go.mod h1:7frBqO0oezxmnO7GF86FY++uy8I0Tk/If5ni1G9Qc0U= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/html/html.go b/html/html.go new file mode 100644 index 0000000..e414d12 --- /dev/null +++ b/html/html.go @@ -0,0 +1,54 @@ +package html + +import ( + _ "embed" + "net/http" + "text/template" // do not use html/template here, we sanitize the message before passing it to the template + + "pages-server/server/context" + + "github.com/microcosm-cc/bluemonday" + "github.com/rs/zerolog/log" +) + +//go:embed templates/error.html +var errorPage string + +var ( + errorTemplate = template.Must(template.New("error").Parse(errorPage)) + sanitizer = createBlueMondayPolicy() +) + +type TemplateContext struct { + StatusCode int + StatusText string + Message string +} + +// ReturnErrorPage sets the response status code and writes the error page to the response body. +// The error page contains a sanitized version of the message and the statusCode both in text and numeric form. +// +// Currently, only the following html tags are supported: +func ReturnErrorPage(ctx *context.Context, msg string, statusCode int) { + ctx.RespWriter.Header().Set("Content-Type", "text/html; charset=utf-8") + ctx.RespWriter.WriteHeader(statusCode) + + templateContext := TemplateContext{ + StatusCode: statusCode, + StatusText: http.StatusText(statusCode), + Message: sanitizer.Sanitize(msg), + } + + err := errorTemplate.Execute(ctx.RespWriter, templateContext) + if err != nil { + log.Err(err).Str("message", msg).Int("status", statusCode).Msg("could not write response") + } +} + +func createBlueMondayPolicy() *bluemonday.Policy { + p := bluemonday.NewPolicy() + + p.AllowElements("code") + + return p +} diff --git a/html/html_test.go b/html/html_test.go new file mode 100644 index 0000000..b395bb2 --- /dev/null +++ b/html/html_test.go @@ -0,0 +1,54 @@ +package html + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSanitizerSimpleString(t *testing.T) { + str := "simple text message without any html elements" + + assert.Equal(t, str, sanitizer.Sanitize(str)) +} + +func TestSanitizerStringWithCodeTag(t *testing.T) { + str := "simple text message with html tag" + + assert.Equal(t, str, sanitizer.Sanitize(str)) +} + +func TestSanitizerStringWithCodeTagWithAttribute(t *testing.T) { + str := "simple text message with html tag" + expected := "simple text message with html tag" + + assert.Equal(t, expected, sanitizer.Sanitize(str)) +} + +func TestSanitizerStringWithATag(t *testing.T) { + str := "simple text message with a link to another page" + expected := "simple text message with a link to another page" + + assert.Equal(t, expected, sanitizer.Sanitize(str)) +} + +func TestSanitizerStringWithATagAndHref(t *testing.T) { + str := "simple text message with a link to another page" + expected := "simple text message with a link to another page" + + assert.Equal(t, expected, sanitizer.Sanitize(str)) +} + +func TestSanitizerStringWithImgTag(t *testing.T) { + str := "simple text message with a \"not" + expected := "simple text message with a " + + assert.Equal(t, expected, sanitizer.Sanitize(str)) +} + +func TestSanitizerStringWithImgTagAndOnerrorAttribute(t *testing.T) { + str := "simple text message with a \"not" + expected := "simple text message with a " + + assert.Equal(t, expected, sanitizer.Sanitize(str)) +} diff --git a/html/templates/error.html b/html/templates/error.html new file mode 100644 index 0000000..05a5d46 --- /dev/null +++ b/html/templates/error.html @@ -0,0 +1,53 @@ + + + + + + {{.StatusText}} + + + + + + + + + + +

{{.StatusText}} ({{.StatusCode}})!

+
+

Sorry, but this page couldn't be served.

+

"{{.Message}}"

+

+ We hope this isn't a problem on our end ;) - Make sure to check the + troubleshooting section in the Docs! +

+
+ + + Static pages made easy - + Codeberg Pages + + + diff --git a/integration/get_test.go b/integration/get_test.go new file mode 100644 index 0000000..cfb7188 --- /dev/null +++ b/integration/get_test.go @@ -0,0 +1,282 @@ +//go:build integration +// +build integration + +package integration + +import ( + "bytes" + "crypto/tls" + "io" + "log" + "net/http" + "net/http/cookiejar" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetRedirect(t *testing.T) { + log.Println("=== TestGetRedirect ===") + // test custom domain redirect + resp, err := getTestHTTPSClient().Get("https://calciumdibromid.localhost.mock.directory:4430") + if !assert.NoError(t, err) { + t.FailNow() + } + if !assert.EqualValues(t, http.StatusTemporaryRedirect, resp.StatusCode) { + t.FailNow() + } + assert.EqualValues(t, "https://www.cabr2.de/", resp.Header.Get("Location")) + assert.EqualValues(t, `Temporary Redirect.`, strings.TrimSpace(string(getBytes(resp.Body)))) +} + +func TestGetContent(t *testing.T) { + log.Println("=== TestGetContent ===") + // test get image + resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/images/827679288a.jpg") + assert.NoError(t, err) + if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) { + t.FailNow() + } + assert.EqualValues(t, "image/jpeg", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "124635", resp.Header.Get("Content-Length")) + assert.EqualValues(t, 124635, getSize(resp.Body)) + assert.Len(t, resp.Header.Get("ETag"), 42) + + // specify branch + resp, err = getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/pag/@master/") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.True(t, getSize(resp.Body) > 1000) + assert.Len(t, resp.Header.Get("ETag"), 44) + + // access branch name contains '/' + resp, err = getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/blumia/@docs~main/") + assert.NoError(t, err) + if !assert.EqualValues(t, http.StatusOK, resp.StatusCode) { + t.FailNow() + } + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.True(t, getSize(resp.Body) > 100) + assert.Len(t, resp.Header.Get("ETag"), 44) + + // TODO: test get of non cacheable content (content size > fileCacheSizeLimit) +} + +func TestCustomDomain(t *testing.T) { + log.Println("=== TestCustomDomain ===") + resp, err := getTestHTTPSClient().Get("https://mock-pages.codeberg-test.org:4430/README.md") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, "text/markdown; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "106", resp.Header.Get("Content-Length")) + assert.EqualValues(t, 106, getSize(resp.Body)) +} + +func TestCustomDomainRedirects(t *testing.T) { + log.Println("=== TestCustomDomainRedirects ===") + // test redirect from default pages domain to custom domain + resp, err := getTestHTTPSClient().Get("https://6543.localhost.mock.directory:4430/test_pages-server_custom-mock-domain/@main/README.md") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusTemporaryRedirect, resp.StatusCode) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + // TODO: custom port is not evaluated (witch does hurt tests & dev env only) + // assert.EqualValues(t, "https://mock-pages.codeberg-test.org:4430/@main/README.md", resp.Header.Get("Location")) + assert.EqualValues(t, "https://mock-pages.codeberg-test.org/@main/README.md", resp.Header.Get("Location")) + assert.EqualValues(t, `https:/codeberg.org/6543/test_pages-server_custom-mock-domain/src/branch/main/README.md; rel="canonical"; rel="canonical"`, resp.Header.Get("Link")) + + // test redirect from an custom domain to the primary custom domain (www.example.com -> example.com) + // regression test to https://codeberg.org/Codeberg/pages-server/issues/153 + resp, err = getTestHTTPSClient().Get("https://mock-pages-redirect.codeberg-test.org:4430/README.md") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusTemporaryRedirect, resp.StatusCode) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + // TODO: custom port is not evaluated (witch does hurt tests & dev env only) + // assert.EqualValues(t, "https://mock-pages.codeberg-test.org:4430/README.md", resp.Header.Get("Location")) + assert.EqualValues(t, "https://mock-pages.codeberg-test.org/README.md", resp.Header.Get("Location")) +} + +func TestRawCustomDomain(t *testing.T) { + log.Println("=== TestRawCustomDomain ===") + // test raw domain response for custom domain branch + resp, err := getTestHTTPSClient().Get("https://raw.localhost.mock.directory:4430/cb_pages_tests/raw-test/example") // need cb_pages_tests fork + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "76", resp.Header.Get("Content-Length")) + assert.EqualValues(t, 76, getSize(resp.Body)) +} + +func TestRawIndex(t *testing.T) { + log.Println("=== TestRawIndex ===") + // test raw domain response for index.html + resp, err := getTestHTTPSClient().Get("https://raw.localhost.mock.directory:4430/cb_pages_tests/raw-test/@branch-test/index.html") // need cb_pages_tests fork + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "597", resp.Header.Get("Content-Length")) + assert.EqualValues(t, 597, getSize(resp.Body)) +} + +func TestGetNotFound(t *testing.T) { + log.Println("=== TestGetNotFound ===") + // test custom not found pages + resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/pages-404-demo/blah") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusNotFound, resp.StatusCode) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "37", resp.Header.Get("Content-Length")) + assert.EqualValues(t, 37, getSize(resp.Body)) +} + +func TestRedirect(t *testing.T) { + log.Println("=== TestRedirect ===") + // test redirects + resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/some_redirects/redirect") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusMovedPermanently, resp.StatusCode) + assert.EqualValues(t, "https://example.com/", resp.Header.Get("Location")) +} + +func TestSPARedirect(t *testing.T) { + log.Println("=== TestSPARedirect ===") + // test SPA redirects + url := "https://cb_pages_tests.localhost.mock.directory:4430/some_redirects/app/aqdjw" + resp, err := getTestHTTPSClient().Get(url) + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, url, resp.Request.URL.String()) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "258", resp.Header.Get("Content-Length")) + assert.EqualValues(t, 258, getSize(resp.Body)) +} + +func TestSplatRedirect(t *testing.T) { + log.Println("=== TestSplatRedirect ===") + // test splat redirects + resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/some_redirects/articles/qfopefe") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusMovedPermanently, resp.StatusCode) + assert.EqualValues(t, "/posts/qfopefe", resp.Header.Get("Location")) +} + +func TestFollowSymlink(t *testing.T) { + log.Printf("=== TestFollowSymlink ===\n") + + // file symlink + resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/tests_for_pages-server/@main/link") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, "application/octet-stream", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "4", resp.Header.Get("Content-Length")) + body := getBytes(resp.Body) + assert.EqualValues(t, 4, len(body)) + assert.EqualValues(t, "abc\n", string(body)) + + // relative file links (../index.html file in this case) + resp, err = getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/tests_for_pages-server/@main/dir_aim/some/") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "an index\n", string(getBytes(resp.Body))) +} + +func TestLFSSupport(t *testing.T) { + log.Printf("=== TestLFSSupport ===\n") + + resp, err := getTestHTTPSClient().Get("https://cb_pages_tests.localhost.mock.directory:4430/tests_for_pages-server/@main/lfs.txt") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusOK, resp.StatusCode) + body := strings.TrimSpace(string(getBytes(resp.Body))) + assert.EqualValues(t, 12, len(body)) + assert.EqualValues(t, "actual value", body) +} + +func TestGetOptions(t *testing.T) { + log.Println("=== TestGetOptions ===") + req, _ := http.NewRequest(http.MethodOptions, "https://mock-pages.codeberg-test.org:4430/README.md", http.NoBody) + resp, err := getTestHTTPSClient().Do(req) + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusNoContent, resp.StatusCode) + assert.EqualValues(t, "GET, HEAD, OPTIONS", resp.Header.Get("Allow")) +} + +func TestHttpRedirect(t *testing.T) { + log.Println("=== TestHttpRedirect ===") + resp, err := getTestHTTPSClient().Get("http://mock-pages.codeberg-test.org:8880/README.md") + assert.NoError(t, err) + if !assert.NotNil(t, resp) { + t.FailNow() + } + assert.EqualValues(t, http.StatusMovedPermanently, resp.StatusCode) + assert.EqualValues(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.EqualValues(t, "https://mock-pages.codeberg-test.org:4430/README.md", resp.Header.Get("Location")) +} + +func getTestHTTPSClient() *http.Client { + cookieJar, _ := cookiejar.New(nil) + return &http.Client{ + Jar: cookieJar, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } +} + +func getBytes(stream io.Reader) []byte { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(stream) + return buf.Bytes() +} + +func getSize(stream io.Reader) int { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(stream) + return buf.Len() +} diff --git a/integration/main_test.go b/integration/main_test.go new file mode 100644 index 0000000..86fd9d3 --- /dev/null +++ b/integration/main_test.go @@ -0,0 +1,69 @@ +//go:build integration +// +build integration + +package integration + +import ( + "context" + "log" + "os" + "testing" + "time" + + "github.com/urfave/cli/v2" + + cmd "codeberg.org/codeberg/pages/cli" + "codeberg.org/codeberg/pages/server" +) + +func TestMain(m *testing.M) { + log.Println("=== TestMain: START Server ===") + serverCtx, serverCancel := context.WithCancel(context.Background()) + if err := startServer(serverCtx); err != nil { + log.Fatalf("could not start server: %v", err) + } + defer func() { + serverCancel() + log.Println("=== TestMain: Server STOPPED ===") + }() + + time.Sleep(10 * time.Second) + + m.Run() +} + +func startServer(ctx context.Context) error { + args := []string{"integration"} + setEnvIfNotSet("ACME_API", "https://acme.mock.directory") + setEnvIfNotSet("PAGES_DOMAIN", "localhost.mock.directory") + setEnvIfNotSet("RAW_DOMAIN", "raw.localhost.mock.directory") + setEnvIfNotSet("PAGES_BRANCHES", "pages,main,master") + setEnvIfNotSet("PORT", "4430") + setEnvIfNotSet("HTTP_PORT", "8880") + setEnvIfNotSet("ENABLE_HTTP_SERVER", "true") + 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.Name = "pages-server" + app.Action = server.Serve + app.Flags = cmd.ServerFlags + + go func() { + if err := app.RunContext(ctx, args); err != nil { + log.Fatalf("run server error: %v", err) + } + }() + + return nil +} + +func setEnvIfNotSet(key, value string) { + if _, set := os.LookupEnv(key); !set { + os.Setenv(key, value) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..62c3aef --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + _ "github.com/joho/godotenv/autoload" + "github.com/rs/zerolog/log" + + "pages-server/cli" + "pages-server/server" +) + +func main() { + app := cli.CreatePagesApp() + app.Action = server.Serve + + if err := app.Run(os.Args); err != nil { + log.Error().Err(err).Msg("A fatal error occurred") + os.Exit(1) + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..9dd1cd7 --- /dev/null +++ b/renovate.json @@ -0,0 +1,27 @@ +{ + "$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"] +} diff --git a/server/cache/interface.go b/server/cache/interface.go new file mode 100644 index 0000000..b3412cc --- /dev/null +++ b/server/cache/interface.go @@ -0,0 +1,10 @@ +package cache + +import "time" + +// ICache is an interface that defines how the pages server interacts with the cache. +type ICache interface { + Set(key string, value interface{}, ttl time.Duration) error + Get(key string) (interface{}, bool) + Remove(key string) +} diff --git a/server/cache/memory.go b/server/cache/memory.go new file mode 100644 index 0000000..093696f --- /dev/null +++ b/server/cache/memory.go @@ -0,0 +1,7 @@ +package cache + +import "github.com/OrlovEvgeny/go-mcache" + +func NewInMemoryCache() ICache { + return mcache.New() +} diff --git a/server/context/context.go b/server/context/context.go new file mode 100644 index 0000000..723653a --- /dev/null +++ b/server/context/context.go @@ -0,0 +1,62 @@ +package context + +import ( + stdContext "context" + "net/http" + + "pages-server/server/utils" +) + +type Context struct { + RespWriter http.ResponseWriter + Req *http.Request + StatusCode int +} + +func New(w http.ResponseWriter, r *http.Request) *Context { + return &Context{ + RespWriter: w, + Req: r, + StatusCode: http.StatusOK, + } +} + +func (c *Context) Context() stdContext.Context { + if c.Req != nil { + return c.Req.Context() + } + return stdContext.Background() +} + +func (c *Context) Response() *http.Response { + if c.Req != nil && c.Req.Response != nil { + return c.Req.Response + } + return nil +} + +func (c *Context) String(raw string, status ...int) { + code := http.StatusOK + if len(status) != 0 { + code = status[0] + } + c.RespWriter.WriteHeader(code) + _, _ = c.RespWriter.Write([]byte(raw)) +} + +func (c *Context) Redirect(uri string, statusCode int) { + http.Redirect(c.RespWriter, c.Req, uri, statusCode) +} + +// Path returns the cleaned requested path. +func (c *Context) Path() string { + return utils.CleanPath(c.Req.URL.Path) +} + +func (c *Context) Host() string { + return c.Req.URL.Host +} + +func (c *Context) TrimHostPort() string { + return utils.TrimHostPort(c.Req.Host) +} diff --git a/server/dns/dns.go b/server/dns/dns.go new file mode 100644 index 0000000..e29e42c --- /dev/null +++ b/server/dns/dns.go @@ -0,0 +1,66 @@ +package dns + +import ( + "net" + "strings" + "time" + + "github.com/hashicorp/golang-lru/v2/expirable" +) + +const ( + lookupCacheValidity = 30 * time.Second + defaultPagesRepo = "pages" +) + +// TODO(#316): refactor to not use global variables +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. +// If everything is fine, it returns the target data. +func GetTargetFromDNS(domain, mainDomainSuffix, firstDefaultBranch string) (targetOwner, targetRepo, targetBranch string) { + // Get CNAME or TXT + var cname string + var err error + + if entry, ok := lookupCache.Get(domain); ok { + cname = entry + } else { + cname, err = net.LookupCNAME(domain) + cname = strings.TrimSuffix(cname, ".") + if err != nil || !strings.HasSuffix(cname, mainDomainSuffix) { + cname = "" + // TODO: check if the A record matches! + names, err := net.LookupTXT(domain) + if err == nil { + for _, name := range names { + name = strings.TrimSuffix(strings.TrimSpace(name), ".") + if strings.HasSuffix(name, mainDomainSuffix) { + cname = name + break + } + } + } + } + _ = lookupCache.Add(domain, cname) + } + if cname == "" { + return + } + cnameParts := strings.Split(strings.TrimSuffix(cname, mainDomainSuffix), ".") + targetOwner = cnameParts[len(cnameParts)-1] + if len(cnameParts) > 1 { + targetRepo = cnameParts[len(cnameParts)-2] + } + if len(cnameParts) > 2 { + targetBranch = cnameParts[len(cnameParts)-3] + } + if targetRepo == "" { + targetRepo = defaultPagesRepo + } + if targetBranch == "" && targetRepo != defaultPagesRepo { + targetBranch = firstDefaultBranch + } + // if targetBranch is still empty, the caller must find the default branch + return +} diff --git a/server/gitea/cache.go b/server/gitea/cache.go new file mode 100644 index 0000000..dde3f14 --- /dev/null +++ b/server/gitea/cache.go @@ -0,0 +1,127 @@ +package gitea + +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" + + "github.com/rs/zerolog/log" + + "pages-server/server/cache" +) + +const ( + // defaultBranchCacheTimeout specifies the timeout for the default branch cache. It can be quite long. + defaultBranchCacheTimeout = 15 * time.Minute + + // branchExistenceCacheTimeout specifies the timeout for the branch timestamp & existence cache. It should be shorter + // than fileCacheTimeout, as that gets invalidated if the branch timestamp has changed. That way, repo changes will be + // picked up faster, while still allowing the content to be cached longer if nothing changes. + branchExistenceCacheTimeout = 5 * time.Minute + + // fileCacheTimeout specifies the timeout for the file content cache - you might want to make this quite long, depending + // on your available memory. + // TODO: move as option into cache interface + 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 = int64(1000 * 1000) +) + +type FileResponse struct { + Exists bool + IsSymlink bool + ETag string + MimeType string + Body []byte +} + +func (f FileResponse) IsEmpty() bool { + return len(f.Body) == 0 +} + +func (f FileResponse) createHttpResponse(cacheKey string) (header http.Header, statusCode int) { + header = make(http.Header) + + if f.Exists { + statusCode = http.StatusOK + } else { + statusCode = http.StatusNotFound + } + + if f.IsSymlink { + header.Set(giteaObjectTypeHeader, objTypeSymlink) + } + header.Set(ETagHeader, f.ETag) + header.Set(ContentTypeHeader, f.MimeType) + header.Set(ContentLengthHeader, fmt.Sprintf("%d", len(f.Body))) + header.Set(PagesCacheIndicatorHeader, "true") + + log.Trace().Msgf("fileCache for %q used", cacheKey) + return header, statusCode +} + +type BranchTimestamp struct { + Branch string + Timestamp time.Time + notFound bool +} + +type writeCacheReader struct { + originalReader io.ReadCloser + buffer *bytes.Buffer + fileResponse *FileResponse + cacheKey string + cache cache.ICache + hasError bool +} + +func (t *writeCacheReader) Read(p []byte) (n int, err error) { + log.Trace().Msgf("[cache] read %q", t.cacheKey) + n, err = t.originalReader.Read(p) + if err != nil && err != io.EOF { + log.Trace().Err(err).Msgf("[cache] original reader for %q has returned an error", t.cacheKey) + t.hasError = true + } else if n > 0 { + _, _ = t.buffer.Write(p[:n]) + } + return +} + +func (t *writeCacheReader) Close() error { + doWrite := !t.hasError + fc := *t.fileResponse + fc.Body = t.buffer.Bytes() + if fc.IsEmpty() { + log.Trace().Msg("[cache] file response is empty") + doWrite = false + } + if doWrite { + err := t.cache.Set(t.cacheKey, fc, fileCacheTimeout) + if err != nil { + log.Trace().Err(err).Msgf("[cache] writer for %q has returned an error", t.cacheKey) + } + } + log.Trace().Msgf("cacheReader for %q saved=%t closed", t.cacheKey, doWrite) + return t.originalReader.Close() +} + +func (f FileResponse) CreateCacheReader(r io.ReadCloser, cache cache.ICache, cacheKey string) io.ReadCloser { + if r == nil || cache == nil || cacheKey == "" { + log.Error().Msg("could not create CacheReader") + return nil + } + + return &writeCacheReader{ + originalReader: r, + buffer: bytes.NewBuffer(make([]byte, 0)), + fileResponse: &f, + cache: cache, + cacheKey: cacheKey, + } +} diff --git a/server/gitea/client.go b/server/gitea/client.go new file mode 100644 index 0000000..e8cfa11 --- /dev/null +++ b/server/gitea/client.go @@ -0,0 +1,330 @@ +package gitea + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "time" + + "code.gitea.io/sdk/gitea" + "github.com/rs/zerolog/log" + + "pages-server/config" + "pages-server/server/cache" + "pages-server/server/version" +) + +var ErrorNotFound = errors.New("not found") + +const ( + // cache key prefixes + branchTimestampCacheKeyPrefix = "branchTime" + defaultBranchCacheKeyPrefix = "defaultBranch" + rawContentCacheKeyPrefix = "rawContent" + ownerExistenceKeyPrefix = "ownerExist" + + // pages server + PagesCacheIndicatorHeader = "X-Pages-Cache" + symlinkReadLimit = 10000 + + // gitea + giteaObjectTypeHeader = "X-Gitea-Object-Type" + objTypeSymlink = "symlink" + + // std + ETagHeader = "ETag" + ContentTypeHeader = "Content-Type" + ContentLengthHeader = "Content-Length" +) + +type Client struct { + sdkClient *gitea.Client + responseCache cache.ICache + + giteaRoot string + + followSymlinks bool + supportLFS bool + + forbiddenMimeTypes map[string]bool + defaultMimeType string +} + +func NewClient(cfg config.ForgeConfig, respCache cache.ICache) (*Client, error) { + // url.Parse returns valid on almost anything... + rootURL, err := url.ParseRequestURI(cfg.Root) + if err != nil { + return nil, fmt.Errorf("invalid forgejo/gitea root url: %w", err) + } + giteaRoot := strings.TrimSuffix(rootURL.String(), "/") + + stdClient := http.Client{Timeout: 10 * time.Second} + + forbiddenMimeTypes := make(map[string]bool, len(cfg.ForbiddenMimeTypes)) + for _, mimeType := range cfg.ForbiddenMimeTypes { + forbiddenMimeTypes[mimeType] = true + } + + defaultMimeType := cfg.DefaultMimeType + if defaultMimeType == "" { + defaultMimeType = "application/octet-stream" + } + + sdk, err := gitea.NewClient( + giteaRoot, + gitea.SetHTTPClient(&stdClient), + gitea.SetToken(cfg.Token), + gitea.SetUserAgent("pages-server/"+version.Version), + ) + + return &Client{ + sdkClient: sdk, + responseCache: respCache, + + giteaRoot: giteaRoot, + + followSymlinks: cfg.FollowSymlinks, + supportLFS: cfg.LFSEnabled, + + forbiddenMimeTypes: forbiddenMimeTypes, + defaultMimeType: defaultMimeType, + }, err +} + +func (client *Client) ContentWebLink(targetOwner, targetRepo, branch, resource string) string { + return path.Join(client.giteaRoot, targetOwner, targetRepo, "src/branch", branch, resource) +} + +func (client *Client) GiteaRawContent(targetOwner, targetRepo, ref, resource string) ([]byte, error) { + reader, _, _, err := client.ServeRawContent(targetOwner, targetRepo, ref, resource) + if err != nil { + return nil, err + } + defer reader.Close() + return io.ReadAll(reader) +} + +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) + log := log.With().Str("cache_key", cacheKey).Logger() + log.Trace().Msg("try file in cache") + // handle if cache entry exist + if cache, ok := client.responseCache.Get(cacheKey); ok { + cache := cache.(FileResponse) + cachedHeader, cachedStatusCode := cache.createHttpResponse(cacheKey) + // TODO: check against some timestamp mismatch?!? + if cache.Exists { + log.Debug().Msg("[cache] exists") + if cache.IsSymlink { + linkDest := string(cache.Body) + log.Debug().Msgf("[cache] follow symlink from %q to %q", resource, linkDest) + return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest) + } else if !cache.IsEmpty() { + log.Debug().Msgf("[cache] return %d bytes", len(cache.Body)) + return io.NopCloser(bytes.NewReader(cache.Body)), cachedHeader, cachedStatusCode, nil + } else if cache.IsEmpty() { + log.Debug().Msg("[cache] is empty") + } + } + } + log.Trace().Msg("file not in cache") + // not in cache, open reader via gitea api + reader, resp, err := client.sdkClient.GetFileReader(targetOwner, targetRepo, ref, resource, client.supportLFS) + if resp != nil { + switch resp.StatusCode { + case http.StatusOK: + // first handle symlinks + { + objType := resp.Header.Get(giteaObjectTypeHeader) + log.Trace().Msgf("server raw content object %q", objType) + if client.followSymlinks && objType == objTypeSymlink { + defer reader.Close() + // read limited chars for symlink + linkDestBytes, err := io.ReadAll(io.LimitReader(reader, symlinkReadLimit)) + if err != nil { + return nil, nil, http.StatusInternalServerError, err + } + linkDest := strings.TrimSpace(string(linkDestBytes)) + + // handle relative links + // we first remove the link from the path, and make a relative join (resolve parent paths like "/../" too) + linkDest = path.Join(path.Dir(resource), linkDest) + + // we store symlink not content to reduce duplicates in cache + fileResponse := FileResponse{ + Exists: true, + IsSymlink: true, + Body: []byte(linkDest), + ETag: resp.Header.Get(ETagHeader), + } + log.Trace().Msgf("file response has %d bytes", len(fileResponse.Body)) + if err := client.responseCache.Set(cacheKey, fileResponse, fileCacheTimeout); err != nil { + log.Error().Err(err).Msg("[cache] error on cache write") + } + + log.Debug().Msgf("follow symlink from %q to %q", resource, linkDest) + return client.ServeRawContent(targetOwner, targetRepo, ref, linkDest) + } + } + + // now we are sure it's content so set the MIME type + mimeType := client.getMimeTypeByExtension(resource) + resp.Response.Header.Set(ContentTypeHeader, mimeType) + + if !shouldRespBeSavedToCache(resp.Response) { + return reader, resp.Response.Header, resp.StatusCode, err + } + + // now we write to cache and respond at the same time + fileResp := FileResponse{ + Exists: true, + ETag: resp.Header.Get(ETagHeader), + MimeType: mimeType, + } + return fileResp.CreateCacheReader(reader, client.responseCache, cacheKey), resp.Response.Header, resp.StatusCode, nil + + case http.StatusNotFound: + if err := client.responseCache.Set(cacheKey, FileResponse{ + Exists: false, + ETag: resp.Header.Get(ETagHeader), + }, fileCacheTimeout); err != nil { + log.Error().Err(err).Msg("[cache] error on cache write") + } + + return nil, resp.Response.Header, http.StatusNotFound, ErrorNotFound + default: + return nil, resp.Response.Header, resp.StatusCode, fmt.Errorf("unexpected status code '%d'", resp.StatusCode) + } + } + return nil, nil, http.StatusInternalServerError, err +} + +func (client *Client) GiteaGetRepoBranchTimestamp(repoOwner, repoName, branchName string) (*BranchTimestamp, error) { + cacheKey := fmt.Sprintf("%s/%s/%s/%s", branchTimestampCacheKeyPrefix, repoOwner, repoName, branchName) + + if stamp, ok := client.responseCache.Get(cacheKey); ok && stamp != nil { + branchTimeStamp := stamp.(*BranchTimestamp) + if branchTimeStamp.notFound { + log.Trace().Msgf("[cache] use branch %q not found", branchName) + return &BranchTimestamp{}, ErrorNotFound + } + log.Trace().Msgf("[cache] use branch %q exist", branchName) + return branchTimeStamp, nil + } + + branch, resp, err := client.sdkClient.GetRepoBranch(repoOwner, repoName, branchName) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + log.Trace().Msgf("[cache] set cache branch %q not found", branchName) + if err := client.responseCache.Set(cacheKey, &BranchTimestamp{Branch: branchName, notFound: true}, branchExistenceCacheTimeout); err != nil { + log.Error().Err(err).Msg("[cache] error on cache write") + } + return &BranchTimestamp{}, ErrorNotFound + } + return &BranchTimestamp{}, err + } + if resp.StatusCode != http.StatusOK { + return &BranchTimestamp{}, fmt.Errorf("unexpected status code '%d'", resp.StatusCode) + } + + stamp := &BranchTimestamp{ + Branch: branch.Name, + Timestamp: branch.Commit.Timestamp, + } + + log.Trace().Msgf("set cache branch [%s] exist", branchName) + if err := client.responseCache.Set(cacheKey, stamp, branchExistenceCacheTimeout); err != nil { + log.Error().Err(err).Msg("[cache] error on cache write") + } + return stamp, nil +} + +func (client *Client) GiteaGetRepoDefaultBranch(repoOwner, repoName string) (string, error) { + cacheKey := fmt.Sprintf("%s/%s/%s", defaultBranchCacheKeyPrefix, repoOwner, repoName) + + if branch, ok := client.responseCache.Get(cacheKey); ok && branch != nil { + return branch.(string), nil + } + + repo, resp, err := client.sdkClient.GetRepo(repoOwner, repoName) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code '%d'", resp.StatusCode) + } + + branch := repo.DefaultBranch + if err := client.responseCache.Set(cacheKey, branch, defaultBranchCacheTimeout); err != nil { + log.Error().Err(err).Msg("[cache] error on cache write") + } + return branch, nil +} + +func (client *Client) GiteaCheckIfOwnerExists(owner string) (bool, error) { + cacheKey := fmt.Sprintf("%s/%s", ownerExistenceKeyPrefix, owner) + + if exist, ok := client.responseCache.Get(cacheKey); ok && exist != nil { + return exist.(bool), nil + } + + _, resp, err := client.sdkClient.GetUserInfo(owner) + if resp.StatusCode == http.StatusOK && err == nil { + if err := client.responseCache.Set(cacheKey, 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, 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, false, ownerExistenceCacheTimeout); err != nil { + log.Error().Err(err).Msg("[cache] error on cache write") + } + return false, nil +} + +func (client *Client) getMimeTypeByExtension(resource string) string { + mimeType := mime.TypeByExtension(path.Ext(resource)) + mimeTypeSplit := strings.SplitN(mimeType, ";", 2) + if client.forbiddenMimeTypes[mimeTypeSplit[0]] || mimeType == "" { + mimeType = client.defaultMimeType + } + log.Trace().Msgf("probe mime of %q is %q", resource, mimeType) + return mimeType +} + +func shouldRespBeSavedToCache(resp *http.Response) bool { + if resp == nil { + return false + } + + contentLengthRaw := resp.Header.Get(ContentLengthHeader) + if contentLengthRaw == "" { + return false + } + + 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 +} diff --git a/server/handler/handler.go b/server/handler/handler.go new file mode 100644 index 0000000..aa5aeeb --- /dev/null +++ b/server/handler/handler.go @@ -0,0 +1,114 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/rs/zerolog/log" + + "pages-server/config" + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" +) + +const ( + headerAccessControlAllowOrigin = "Access-Control-Allow-Origin" + headerAccessControlAllowMethods = "Access-Control-Allow-Methods" + defaultPagesRepo = "pages" +) + +// Handler handles a single HTTP request to the web server. +func Handler( + cfg config.ServerConfig, + giteaClient *gitea.Client, + canonicalDomainCache, redirectsCache cache.ICache, +) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + log.Debug().Msg("\n----------------------------------------------------------") + log := log.With().Strs("Handler", []string{req.Host, req.RequestURI}).Logger() + ctx := context.New(w, req) + + ctx.RespWriter.Header().Set("Server", "pages-server") + + // Force new default from specification (since November 2020) - see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy#strict-origin-when-cross-origin + ctx.RespWriter.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + + // Enable browser caching for up to 10 minutes + ctx.RespWriter.Header().Set("Cache-Control", "public, max-age=600") + + trimmedHost := ctx.TrimHostPort() + + // Add HSTS for RawDomain and MainDomain + if hsts := getHSTSHeader(trimmedHost, cfg.MainDomain, cfg.RawDomain); hsts != "" { + ctx.RespWriter.Header().Set("Strict-Transport-Security", hsts) + } + + // Handle all http methods + ctx.RespWriter.Header().Set("Allow", http.MethodGet+", "+http.MethodHead+", "+http.MethodOptions) + switch ctx.Req.Method { + case http.MethodOptions: + // return Allow header + ctx.RespWriter.WriteHeader(http.StatusNoContent) + return + case http.MethodGet, + http.MethodHead: + // end switch case and handle allowed requests + break + default: + // Block all methods not required for static pages + ctx.String("Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Block blacklisted paths (like ACME challenges) + for _, blacklistedPath := range cfg.BlacklistedPaths { + if strings.HasPrefix(ctx.Path(), blacklistedPath) { + html.ReturnErrorPage(ctx, "requested path is blacklisted", http.StatusForbidden) + return + } + } + + // Allow CORS for specified domains + allowCors := false + for _, allowedCorsDomain := range cfg.AllowedCorsDomains { + if strings.EqualFold(trimmedHost, allowedCorsDomain) { + allowCors = true + break + } + } + if allowCors { + ctx.RespWriter.Header().Set(headerAccessControlAllowOrigin, "*") + ctx.RespWriter.Header().Set(headerAccessControlAllowMethods, http.MethodGet+", "+http.MethodHead) + } + + // Prepare request information to Gitea + pathElements := strings.Split(strings.Trim(ctx.Path(), "/"), "/") + + if cfg.RawDomain != "" && strings.EqualFold(trimmedHost, cfg.RawDomain) { + log.Debug().Msg("raw domain request detected") + handleRaw(log, ctx, giteaClient, + cfg.MainDomain, + trimmedHost, + pathElements, + canonicalDomainCache, redirectsCache) + } else if strings.HasSuffix(trimmedHost, cfg.MainDomain) { + log.Debug().Msg("subdomain request detected") + handleSubDomain(log, ctx, giteaClient, + cfg.MainDomain, + cfg.PagesBranches, + trimmedHost, + pathElements, + canonicalDomainCache, redirectsCache) + } else { + log.Debug().Msg("custom domain request detected") + handleCustomDomain(log, ctx, giteaClient, + cfg.MainDomain, + trimmedHost, + pathElements, + cfg.PagesBranches[0], + canonicalDomainCache, redirectsCache) + } + } +} diff --git a/server/handler/handler_custom_domain.go b/server/handler/handler_custom_domain.go new file mode 100644 index 0000000..c29bdd3 --- /dev/null +++ b/server/handler/handler_custom_domain.go @@ -0,0 +1,73 @@ +package handler + +import ( + "net/http" + "path" + "strings" + + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/dns" + "pages-server/server/gitea" + "pages-server/server/upstream" + + "github.com/rs/zerolog" +) + +func handleCustomDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client, + mainDomainSuffix string, + trimmedHost string, + pathElements []string, + firstDefaultBranch string, + canonicalDomainCache, redirectsCache cache.ICache, +) { + // Serve pages from custom domains + targetOwner, targetRepo, targetBranch := dns.GetTargetFromDNS(trimmedHost, mainDomainSuffix, firstDefaultBranch) + if targetOwner == "" { + html.ReturnErrorPage(ctx, + "could not obtain repo owner from custom domain", + http.StatusFailedDependency) + return + } + + pathParts := pathElements + canonicalLink := false + if strings.HasPrefix(pathElements[0], "@") { + targetBranch = pathElements[0][1:] + pathParts = pathElements[1:] + canonicalLink = true + } + + // Try to use the given repo on the given branch or the default branch + log.Debug().Msg("custom domain preparations, now trying with details from DNS") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: true, + TargetOwner: targetOwner, + TargetRepo: targetRepo, + TargetBranch: targetBranch, + TargetPath: path.Join(pathParts...), + }, canonicalLink); works { + canonicalDomain, valid := targetOpt.CheckCanonicalDomain(giteaClient, trimmedHost, mainDomainSuffix, canonicalDomainCache) + if !valid { + html.ReturnErrorPage(ctx, "domain not specified in .domains file", http.StatusMisdirectedRequest) + return + } else if canonicalDomain != trimmedHost { + // only redirect if the target is also a codeberg page! + targetOwner, _, _ = dns.GetTargetFromDNS(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix, firstDefaultBranch) + if targetOwner != "" { + ctx.Redirect("http://"+canonicalDomain+"/"+targetOpt.TargetPath, http.StatusTemporaryRedirect) + return + } + + html.ReturnErrorPage(ctx, "target is no codeberg page", http.StatusFailedDependency) + return + } + + log.Debug().Msg("tryBranch, now trying upstream 7") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + return + } + + html.ReturnErrorPage(ctx, "could not find target for custom domain", http.StatusFailedDependency) +} diff --git a/server/handler/handler_raw_domain.go b/server/handler/handler_raw_domain.go new file mode 100644 index 0000000..86d98a0 --- /dev/null +++ b/server/handler/handler_raw_domain.go @@ -0,0 +1,71 @@ +package handler + +import ( + "fmt" + "net/http" + "path" + "strings" + + "github.com/rs/zerolog" + + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" + "pages-server/server/upstream" +) + +func handleRaw(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client, + mainDomainSuffix string, + trimmedHost string, + pathElements []string, + canonicalDomainCache, redirectsCache cache.ICache, +) { + // Serve raw content from RawDomain + log.Debug().Msg("raw domain") + + if len(pathElements) < 2 { + html.ReturnErrorPage( + ctx, + "a url in the form of http://{domain}/{owner}/{repo}[/@{branch}]/{path} is required", + http.StatusBadRequest, + ) + + return + } + + // raw.codeberg.org/example/myrepo/@main/index.html + if len(pathElements) > 2 && strings.HasPrefix(pathElements[2], "@") { + log.Debug().Msg("raw domain preparations, now trying with specified branch") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + ServeRaw: true, + TargetOwner: pathElements[0], + TargetRepo: pathElements[1], + TargetBranch: pathElements[2][1:], + TargetPath: path.Join(pathElements[3:]...), + }, true); works { + log.Trace().Msg("tryUpstream: serve raw domain with specified branch") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + return + } + log.Debug().Msg("missing branch info") + html.ReturnErrorPage(ctx, "missing branch info", http.StatusFailedDependency) + return + } + + log.Debug().Msg("raw domain preparations, now trying with default branch") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: false, + ServeRaw: true, + TargetOwner: pathElements[0], + TargetRepo: pathElements[1], + TargetPath: path.Join(pathElements[2:]...), + }, true); works { + log.Trace().Msg("tryUpstream: serve raw domain with default branch") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + } else { + html.ReturnErrorPage(ctx, + fmt.Sprintf("raw domain could not find repo %s/%s or repo is empty", targetOpt.TargetOwner, targetOpt.TargetRepo), + http.StatusNotFound) + } +} diff --git a/server/handler/handler_sub_domain.go b/server/handler/handler_sub_domain.go new file mode 100644 index 0000000..f906504 --- /dev/null +++ b/server/handler/handler_sub_domain.go @@ -0,0 +1,156 @@ +package handler + +import ( + "fmt" + "net/http" + "path" + "strings" + + "github.com/rs/zerolog" + "golang.org/x/exp/slices" + + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" + "pages-server/server/upstream" +) + +func handleSubDomain(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client, + mainDomainSuffix string, + defaultPagesBranches []string, + trimmedHost string, + pathElements []string, + canonicalDomainCache, redirectsCache cache.ICache, +) { + // Serve pages from subdomains of MainDomainSuffix + log.Debug().Msg("main domain suffix") + + targetOwner := strings.TrimSuffix(trimmedHost, mainDomainSuffix) + targetRepo := pathElements[0] + + if targetOwner == "www" { + // www.codeberg.page redirects to codeberg.page // TODO: rm hardcoded - use cname? + ctx.Redirect("http://"+mainDomainSuffix[1:]+ctx.Path(), http.StatusPermanentRedirect) + return + } + + // Check if the first directory is a repo with the second directory as a branch + // example.codeberg.page/myrepo/@main/index.html + if len(pathElements) > 1 && strings.HasPrefix(pathElements[1], "@") { + if targetRepo == defaultPagesRepo { + // example.codeberg.org/pages/@... redirects to example.codeberg.org/@... + ctx.Redirect("/"+strings.Join(pathElements[1:], "/"), http.StatusTemporaryRedirect) + return + } + + log.Debug().Msg("main domain preparations, now trying with specified repo & branch") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: true, + TargetOwner: targetOwner, + TargetRepo: pathElements[0], + TargetBranch: pathElements[1][1:], + TargetPath: path.Join(pathElements[2:]...), + }, true); works { + log.Trace().Msg("tryUpstream: serve with specified repo and branch") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + } else { + html.ReturnErrorPage( + ctx, + formatSetBranchNotFoundMessage(pathElements[1][1:], targetOwner, pathElements[0]), + http.StatusFailedDependency, + ) + } + return + } + + // Check if the first directory is a branch for the defaultPagesRepo + // example.codeberg.page/@main/index.html + if strings.HasPrefix(pathElements[0], "@") { + targetBranch := pathElements[0][1:] + + // if the default pages branch can be determined exactly, it does not need to be set + if len(defaultPagesBranches) == 1 && slices.Contains(defaultPagesBranches, targetBranch) { + // example.codeberg.org/@pages/... redirects to example.codeberg.org/... + ctx.Redirect("/"+strings.Join(pathElements[1:], "/"), http.StatusTemporaryRedirect) + return + } + + log.Debug().Msg("main domain preparations, now trying with specified branch") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: true, + TargetOwner: targetOwner, + TargetRepo: defaultPagesRepo, + TargetBranch: targetBranch, + TargetPath: path.Join(pathElements[1:]...), + }, true); works { + log.Trace().Msg("tryUpstream: serve default pages repo with specified branch") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + } else { + html.ReturnErrorPage( + ctx, + formatSetBranchNotFoundMessage(targetBranch, targetOwner, defaultPagesRepo), + http.StatusFailedDependency, + ) + } + return + } + + for _, defaultPagesBranch := range defaultPagesBranches { + // Check if the first directory is a repo with a default pages branch + // example.codeberg.page/myrepo/index.html + // example.codeberg.page/{PAGES_BRANCHE}/... is not allowed here. + log.Debug().Msg("main domain preparations, now trying with specified repo") + if pathElements[0] != defaultPagesBranch { + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: true, + TargetOwner: targetOwner, + TargetRepo: pathElements[0], + TargetBranch: defaultPagesBranch, + TargetPath: path.Join(pathElements[1:]...), + }, false); works { + log.Debug().Msg("tryBranch, now trying upstream 5") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + return + } + } + + // Try to use the defaultPagesRepo on an default pages branch + // example.codeberg.page/index.html + log.Debug().Msg("main domain preparations, now trying with default repo") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: true, + TargetOwner: targetOwner, + TargetRepo: defaultPagesRepo, + TargetBranch: defaultPagesBranch, + TargetPath: path.Join(pathElements...), + }, false); works { + log.Debug().Msg("tryBranch, now trying upstream 6") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + return + } + } + + // Try to use the defaultPagesRepo on its default branch + // example.codeberg.page/index.html + log.Debug().Msg("main domain preparations, now trying with default repo/branch") + if targetOpt, works := tryBranch(log, ctx, giteaClient, &upstream.Options{ + TryIndexPages: true, + TargetOwner: targetOwner, + TargetRepo: defaultPagesRepo, + TargetPath: path.Join(pathElements...), + }, false); works { + log.Debug().Msg("tryBranch, now trying upstream 6") + tryUpstream(ctx, giteaClient, mainDomainSuffix, trimmedHost, targetOpt, canonicalDomainCache, redirectsCache) + return + } + + // Couldn't find a valid repo/branch + html.ReturnErrorPage(ctx, + fmt.Sprintf("could not find a valid repository or branch for repository: %s", targetRepo), + http.StatusNotFound) +} + +func formatSetBranchNotFoundMessage(branch, owner, repo string) string { + return fmt.Sprintf("explicitly set branch %q does not exist at %s/%s", branch, owner, repo) +} diff --git a/server/handler/handler_test.go b/server/handler/handler_test.go new file mode 100644 index 0000000..acba71e --- /dev/null +++ b/server/handler/handler_test.go @@ -0,0 +1,59 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "pages-server/config" + "pages-server/server/cache" + "pages-server/server/gitea" + + "github.com/rs/zerolog/log" +) + +func TestHandlerPerformance(t *testing.T) { + cfg := config.ForgeConfig{ + Root: "https://codeberg.org", + Token: "", + LFSEnabled: false, + FollowSymlinks: false, + } + giteaClient, _ := gitea.NewClient(cfg, cache.NewInMemoryCache()) + serverCfg := config.ServerConfig{ + MainDomain: "codeberg.page", + RawDomain: "raw.codeberg.page", + 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) { + t.Run(uri, func(t *testing.T) { + req := httptest.NewRequest("GET", uri, http.NoBody) + w := httptest.NewRecorder() + + log.Printf("Start: %v\n", time.Now()) + start := time.Now() + testHandler(w, req) + end := time.Now() + log.Printf("Done: %v\n", time.Now()) + + resp := w.Result() + + if resp.StatusCode != status { + t.Errorf("request failed with status code %d", resp.StatusCode) + } else { + t.Logf("request took %d milliseconds", end.Sub(start).Milliseconds()) + } + }) + } + + testCase("https://mondstern.codeberg.page/", 404) // TODO: expect 200 + testCase("https://codeberg.page/", 404) // TODO: expect 200 + testCase("https://example.momar.xyz/", 424) +} diff --git a/server/handler/hsts.go b/server/handler/hsts.go new file mode 100644 index 0000000..1ab73ae --- /dev/null +++ b/server/handler/hsts.go @@ -0,0 +1,15 @@ +package handler + +import ( + "strings" +) + +// getHSTSHeader returns a HSTS header with includeSubdomains & preload for MainDomainSuffix and RawDomain, or an empty +// string for custom domains. +func getHSTSHeader(host, mainDomainSuffix, rawDomain string) string { + if strings.HasSuffix(host, mainDomainSuffix) || strings.EqualFold(host, rawDomain) { + return "max-age=63072000; includeSubdomains; preload" + } else { + return "" + } +} diff --git a/server/handler/try.go b/server/handler/try.go new file mode 100644 index 0000000..bf04e5d --- /dev/null +++ b/server/handler/try.go @@ -0,0 +1,78 @@ +package handler + +import ( + "fmt" + "net/http" + "strings" + + "github.com/rs/zerolog" + + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" + "pages-server/server/upstream" +) + +// tryUpstream forwards the target request to the Gitea API, and shows an error page on failure. +func tryUpstream(ctx *context.Context, giteaClient *gitea.Client, + mainDomainSuffix, trimmedHost string, + options *upstream.Options, + canonicalDomainCache cache.ICache, + redirectsCache cache.ICache, +) { + // check if a canonical domain exists on a request on MainDomain + if strings.HasSuffix(trimmedHost, mainDomainSuffix) && !options.ServeRaw { + canonicalDomain, _ := options.CheckCanonicalDomain(giteaClient, "", mainDomainSuffix, canonicalDomainCache) + if !strings.HasSuffix(strings.SplitN(canonicalDomain, "/", 2)[0], mainDomainSuffix) { + canonicalPath := ctx.Req.RequestURI + if options.TargetRepo != defaultPagesRepo { + path := strings.SplitN(canonicalPath, "/", 3) + if len(path) >= 3 { + canonicalPath = "/" + path[2] + } + } + ctx.Redirect("http://"+canonicalDomain+canonicalPath, http.StatusTemporaryRedirect) + return + } + } + + // Add host for debugging. + options.Host = trimmedHost + + // Try to request the file from the Gitea API + if !options.Upstream(ctx, giteaClient, redirectsCache) { + html.ReturnErrorPage(ctx, fmt.Sprintf("Forge returned %d %s", ctx.StatusCode, http.StatusText(ctx.StatusCode)), ctx.StatusCode) + } +} + +// tryBranch checks if a branch exists and populates the target variables. If canonicalLink is non-empty, +// it will also disallow search indexing and add a Link header to the canonical URL. +func tryBranch(log zerolog.Logger, ctx *context.Context, giteaClient *gitea.Client, + targetOptions *upstream.Options, canonicalLink bool, +) (*upstream.Options, bool) { + if targetOptions.TargetOwner == "" || targetOptions.TargetRepo == "" { + log.Debug().Msg("tryBranch: owner or repo is empty") + return nil, false + } + + // Replace "~" to "/" so we can access branch that contains slash character + // Branch name cannot contain "~" so doing this is okay + targetOptions.TargetBranch = strings.ReplaceAll(targetOptions.TargetBranch, "~", "/") + + // Check if the branch exists, otherwise treat it as a file path + branchExist, _ := targetOptions.GetBranchTimestamp(giteaClient) + if !branchExist { + log.Debug().Msg("tryBranch: branch doesn't exist") + return nil, false + } + + if canonicalLink { + // Hide from search machines & add canonical link + ctx.RespWriter.Header().Set("X-Robots-Tag", "noarchive, noindex") + ctx.RespWriter.Header().Set("Link", targetOptions.ContentWebLink(giteaClient)+"; rel=\"canonical\"") + } + + log.Debug().Msg("tryBranch: true") + return targetOptions, true +} diff --git a/server/profiling.go b/server/profiling.go new file mode 100644 index 0000000..7d20926 --- /dev/null +++ b/server/profiling.go @@ -0,0 +1,21 @@ +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") + }() +} diff --git a/server/startup.go b/server/startup.go new file mode 100644 index 0000000..0905d6a --- /dev/null +++ b/server/startup.go @@ -0,0 +1,94 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "os" + "strings" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/urfave/cli/v2" + + "pages-server/config" + "pages-server/server/cache" + "pages-server/server/gitea" + "pages-server/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().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 + } + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}).With().Timestamp().Logger().Level(logLevel) + + listeningHTTPAddress := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + + 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)") + } + + // 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) + } + + // Create listener + log.Info().Msgf("Create TCP listener on %s", listeningHTTPAddress) + listener, err := net.Listen("tcp", listeningHTTPAddress) + if err != nil { + return fmt.Errorf("couldn't create listener: %v", err) + } + + // // Create listener for http and start listening + // go func() { + // log.Info().Msgf("Start HTTP server listening on %s", listeningHTTPAddress) + // err := http.ListenAndServe(listeningHTTPAddress, nil) + // if err != nil { + // log.Error().Err(err).Msg("Couldn't start HTTP server") + // } + // }() + + if ctx.IsSet("enable-profiling") { + StartProfilingServer(ctx.String("profiling-address")) + } + + // Create handler based on settings + httpHandler := handler.Handler(cfg.Server, giteaClient, canonicalDomainCache, redirectsCache) + + // Start the listener + log.Info().Msgf("Start server using TCP listener on %s", listener.Addr()) + + return http.Serve(listener, httpHandler) +} diff --git a/server/upstream/domains.go b/server/upstream/domains.go new file mode 100644 index 0000000..39c5f0f --- /dev/null +++ b/server/upstream/domains.go @@ -0,0 +1,70 @@ +package upstream + +import ( + "errors" + "strings" + "time" + + "github.com/rs/zerolog/log" + + "pages-server/server/cache" + "pages-server/server/gitea" +) + +// canonicalDomainCacheTimeout specifies the timeout for the canonical domain cache. +var canonicalDomainCacheTimeout = 15 * time.Minute + +const canonicalDomainConfig = ".domains" + +// CheckCanonicalDomain returns the canonical domain specified in the repo (using the `.domains` file). +func (o *Options) CheckCanonicalDomain(giteaClient *gitea.Client, actualDomain, mainDomainSuffix string, canonicalDomainCache cache.ICache) (domain string, valid bool) { + // Check if this request is cached. + if cachedValue, ok := canonicalDomainCache.Get(o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch); ok { + domains := cachedValue.([]string) + for _, domain := range domains { + if domain == actualDomain { + valid = true + break + } + } + return domains[0], valid + } + + body, err := giteaClient.GiteaRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, canonicalDomainConfig) + 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) + } + + var domains []string + for _, domain := range strings.Split(string(body), "\n") { + domain = strings.ToLower(domain) + domain = strings.TrimSpace(domain) + domain = strings.TrimPrefix(domain, "http://") + domain = strings.TrimPrefix(domain, "http://") + if domain != "" && !strings.HasPrefix(domain, "#") && !strings.ContainsAny(domain, "\t /") && strings.ContainsRune(domain, '.') { + domains = append(domains, domain) + } + if domain == actualDomain { + valid = true + } + } + + // Add [owner].[pages-domain] as valid domain. + domains = append(domains, o.TargetOwner+mainDomainSuffix) + if domains[len(domains)-1] == actualDomain { + valid = true + } + + // If the target repository isn't called pages, add `/[repository]` to the + // previous valid domain. + if o.TargetRepo != "" && o.TargetRepo != "pages" { + domains[len(domains)-1] += "/" + o.TargetRepo + } + + // Add result to cache. + _ = canonicalDomainCache.Set(o.TargetOwner+"/"+o.TargetRepo+"/"+o.TargetBranch, domains, canonicalDomainCacheTimeout) + + // Return the first domain from the list and return if any of the domains + // matched the requested domain. + return domains[0], valid +} diff --git a/server/upstream/header.go b/server/upstream/header.go new file mode 100644 index 0000000..d81f248 --- /dev/null +++ b/server/upstream/header.go @@ -0,0 +1,28 @@ +package upstream + +import ( + "net/http" + "time" + + "pages-server/server/context" + "pages-server/server/gitea" +) + +// setHeader set values to response header +func (o *Options) setHeader(ctx *context.Context, header http.Header) { + if eTag := header.Get(gitea.ETagHeader); eTag != "" { + ctx.RespWriter.Header().Set(gitea.ETagHeader, eTag) + } + if cacheIndicator := header.Get(gitea.PagesCacheIndicatorHeader); cacheIndicator != "" { + ctx.RespWriter.Header().Set(gitea.PagesCacheIndicatorHeader, cacheIndicator) + } + if length := header.Get(gitea.ContentLengthHeader); length != "" { + ctx.RespWriter.Header().Set(gitea.ContentLengthHeader, length) + } + if mime := header.Get(gitea.ContentTypeHeader); mime == "" || o.ServeRaw { + ctx.RespWriter.Header().Set(gitea.ContentTypeHeader, rawMime) + } else { + ctx.RespWriter.Header().Set(gitea.ContentTypeHeader, mime) + } + ctx.RespWriter.Header().Set(headerLastModified, o.BranchTimestamp.In(time.UTC).Format(http.TimeFormat)) +} diff --git a/server/upstream/helper.go b/server/upstream/helper.go new file mode 100644 index 0000000..314dbfa --- /dev/null +++ b/server/upstream/helper.go @@ -0,0 +1,47 @@ +package upstream + +import ( + "errors" + "fmt" + + "github.com/rs/zerolog/log" + + "pages-server/server/gitea" +) + +// GetBranchTimestamp finds the default branch (if branch is "") and save branch and it's last modification time to Options +func (o *Options) GetBranchTimestamp(giteaClient *gitea.Client) (bool, error) { + log := log.With().Strs("BranchInfo", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch}).Logger() + + if o.TargetBranch == "" { + // Get default branch + defaultBranch, err := giteaClient.GiteaGetRepoDefaultBranch(o.TargetOwner, o.TargetRepo) + if err != nil { + log.Err(err).Msg("Couldn't fetch default branch from repository") + return false, err + } + log.Debug().Msgf("Successfully fetched default branch %q from Gitea", defaultBranch) + o.TargetBranch = defaultBranch + } + + timestamp, err := giteaClient.GiteaGetRepoBranchTimestamp(o.TargetOwner, o.TargetRepo, o.TargetBranch) + if err != nil { + if !errors.Is(err, gitea.ErrorNotFound) { + log.Error().Err(err).Msg("Could not get latest commit timestamp from branch") + } + return false, err + } + + if timestamp == nil || timestamp.Branch == "" { + return false, fmt.Errorf("empty response") + } + + log.Debug().Msgf("Successfully fetched latest commit timestamp from branch: %#v", timestamp) + o.BranchTimestamp = timestamp.Timestamp + o.TargetBranch = timestamp.Branch + return true, nil +} + +func (o *Options) ContentWebLink(giteaClient *gitea.Client) string { + return giteaClient.ContentWebLink(o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath) + "; rel=\"canonical\"" +} diff --git a/server/upstream/redirects.go b/server/upstream/redirects.go new file mode 100644 index 0000000..7a7a8f9 --- /dev/null +++ b/server/upstream/redirects.go @@ -0,0 +1,107 @@ +package upstream + +import ( + "strconv" + "strings" + "time" + + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" + "github.com/rs/zerolog/log" +) + +type Redirect struct { + From string + To string + 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. +var redirectsCacheTimeout = 10 * time.Minute + +const redirectsConfig = "_redirects" + +// getRedirects returns redirects specified in the _redirects file. +func (o *Options) getRedirects(giteaClient *gitea.Client, redirectsCache cache.ICache) []Redirect { + var redirects []Redirect + cacheKey := o.TargetOwner + "/" + o.TargetRepo + "/" + o.TargetBranch + + // Check for cached redirects + if cachedValue, ok := redirectsCache.Get(cacheKey); ok { + redirects = cachedValue.([]Redirect) + } else { + // Get _redirects file and parse + body, err := giteaClient.GiteaRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, redirectsConfig) + if err == nil { + for _, line := range strings.Split(string(body), "\n") { + redirectArr := strings.Fields(line) + + // Ignore comments and invalid lines + if strings.HasPrefix(line, "#") || len(redirectArr) < 2 { + continue + } + + // Get redirect status code + statusCode := 301 + if len(redirectArr) == 3 { + statusCode, err = strconv.Atoi(redirectArr[2]) + if err != nil { + log.Info().Err(err).Msgf("could not read %s of %s/%s", redirectsConfig, o.TargetOwner, o.TargetRepo) + } + } + + redirects = append(redirects, Redirect{ + From: redirectArr[0], + To: redirectArr[1], + StatusCode: statusCode, + }) + } + } + _ = redirectsCache.Set(cacheKey, redirects, redirectsCacheTimeout) + } + return redirects +} + +func (o *Options) matchRedirects(ctx *context.Context, giteaClient *gitea.Client, redirects []Redirect, redirectsCache cache.ICache) (final bool) { + reqURL := ctx.Req.RequestURI + // remove repo and branch from request url + reqURL = strings.TrimPrefix(reqURL, "/"+o.TargetRepo) + reqURL = strings.TrimPrefix(reqURL, "/@"+o.TargetBranch) + + for _, redirect := range redirects { + if dstURL, ok := redirect.rewriteURL(reqURL); ok { + // do rewrite if status code is 200 + if redirect.StatusCode == 200 { + o.TargetPath = dstURL + o.Upstream(ctx, giteaClient, redirectsCache) + } else { + ctx.Redirect(dstURL, redirect.StatusCode) + } + return true + } + } + + return false +} diff --git a/server/upstream/redirects_test.go b/server/upstream/redirects_test.go new file mode 100644 index 0000000..6118a70 --- /dev/null +++ b/server/upstream/redirects_test.go @@ -0,0 +1,36 @@ +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) + } + } +} diff --git a/server/upstream/upstream.go b/server/upstream/upstream.go new file mode 100644 index 0000000..b6539fe --- /dev/null +++ b/server/upstream/upstream.go @@ -0,0 +1,225 @@ +package upstream + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" +) + +const ( + headerLastModified = "Last-Modified" + headerIfModifiedSince = "If-Modified-Since" + + rawMime = "text/plain; charset=utf-8" +) + +var upstreamIndexPages = []string{ + "index.html", +} + +var upstreamNotFoundPages = []string{ + "404.html", +} + +type Options struct { + TargetOwner string + TargetRepo string + TargetBranch string + TargetPath string + + Host string + + TryIndexPages bool + BranchTimestamp time.Time + + appendTrailingSlash bool + redirectIfExists string + + ServeRaw bool +} + +func (o *Options) Upstream(ctx *context.Context, giteaClient *gitea.Client, redirectsCache cache.ICache) bool { + log := log.With().Strs("upstream", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath}).Logger() + + log.Debug().Msg("Start") + + if o.TargetOwner == "" || o.TargetRepo == "" { + html.ReturnErrorPage(ctx, "forge client: either repo owner or name info is missing", http.StatusBadRequest) + return false + } + + if o.BranchTimestamp.IsZero() { + branchExist, err := o.GetBranchTimestamp(giteaClient) + if err != nil && errors.Is(err, gitea.ErrorNotFound) || !branchExist { + html.ReturnErrorPage(ctx, + fmt.Sprintf("branch %q for %s/%s not found", o.TargetBranch, o.TargetOwner, o.TargetRepo), + http.StatusNotFound) + return false + } + + if err != nil { + html.ReturnErrorPage(ctx, + fmt.Sprintf("could not get timestamp of branch %q: '%v'", o.TargetBranch, err), + http.StatusFailedDependency) + return false + } + } + + if ctx.Response() != nil { + ifModifiedSince, err := time.Parse(time.RFC1123, ctx.Response().Header.Get(headerIfModifiedSince)) + if err == nil && ifModifiedSince.After(o.BranchTimestamp) { + ctx.RespWriter.WriteHeader(http.StatusNotModified) + log.Trace().Msg("check response against last modified: valid") + return true + } + log.Trace().Msg("check response against last modified: outdated") + } + + reader, header, statusCode, err := giteaClient.ServeRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath) + if err != nil { + handleGiteaError(ctx, log, err, statusCode) + return false + } + + defer func() { + if reader != nil { + reader.Close() + } + }() + + if errors.Is(err, gitea.ErrorNotFound) { + handleNotFound(ctx, log, giteaClient, redirectsCache, o) + return false + } + + if err != nil || reader == nil || statusCode != http.StatusOK { + handleUnexpectedError(ctx, log, err, statusCode) + return false + } + + handleRedirects(ctx, log, o, redirectsCache) + setHeaders(ctx, header) + writeResponse(ctx, reader) + + return true +} + +func handleGiteaError(ctx *context.Context, log zerolog.Logger, err error, statusCode int) { + var msg string + if err != nil { + msg = "forge client: returned unexpected error" + log.Error().Err(err).Msg(msg) + msg = fmt.Sprintf("%s: '%v'", msg, err) + } + if statusCode != http.StatusOK { + msg = fmt.Sprintf("forge client: couldn't fetch contents: %d - %s", statusCode, http.StatusText(statusCode)) + log.Error().Msg(msg) + } + + html.ReturnErrorPage(ctx, msg, http.StatusInternalServerError) +} + +func handleNotFound(ctx *context.Context, log zerolog.Logger, giteaClient *gitea.Client, redirectsCache cache.ICache, o *Options) { + log.Debug().Msg("Handling not found error") + redirects := o.getRedirects(giteaClient, redirectsCache) + if o.matchRedirects(ctx, giteaClient, redirects, redirectsCache) { + log.Trace().Msg("redirect") + return + } + + if o.TryIndexPages { + log.Trace().Msg("try index page") + optionsForIndexPages := *o + optionsForIndexPages.TryIndexPages = false + optionsForIndexPages.appendTrailingSlash = true + for _, indexPage := range upstreamIndexPages { + optionsForIndexPages.TargetPath = strings.TrimSuffix(o.TargetPath, "/") + "/" + indexPage + if optionsForIndexPages.Upstream(ctx, giteaClient, redirectsCache) { + return + } + } + log.Trace().Msg("try html file with path name") + optionsForIndexPages.appendTrailingSlash = false + optionsForIndexPages.redirectIfExists = strings.TrimSuffix(ctx.Path(), "/") + ".html" + optionsForIndexPages.TargetPath = o.TargetPath + ".html" + if optionsForIndexPages.Upstream(ctx, giteaClient, redirectsCache) { + return + } + } + + log.Trace().Msg("not found") + ctx.StatusCode = http.StatusNotFound + + if o.TryIndexPages { + log.Trace().Msg("try not found page") + optionsForNotFoundPages := *o + optionsForNotFoundPages.TryIndexPages = false + optionsForNotFoundPages.appendTrailingSlash = false + for _, notFoundPage := range upstreamNotFoundPages { + optionsForNotFoundPages.TargetPath = "/" + notFoundPage + if optionsForNotFoundPages.Upstream(ctx, giteaClient, redirectsCache) { + return + } + } + log.Trace().Msg("not found page missing") + } +} + +func handleUnexpectedError(ctx *context.Context, log zerolog.Logger, err error, statusCode int) { + var msg string + if err != nil { + msg = "forge client: returned unexpected error" + log.Error().Err(err).Msg(msg) + msg = fmt.Sprintf("%s: '%v'", msg, err) + } + if statusCode != http.StatusOK { + msg = fmt.Sprintf("forge client: couldn't fetch contents: %d - %s", statusCode, http.StatusText(statusCode)) + log.Error().Msg(msg) + } + + html.ReturnErrorPage(ctx, msg, http.StatusInternalServerError) +} + +func handleRedirects(ctx *context.Context, log zerolog.Logger, o *Options, redirectsCache cache.ICache) { + if o.appendTrailingSlash && !strings.HasSuffix(ctx.Path(), "/") { + log.Trace().Msg("append trailing slash and redirect") + ctx.Redirect(ctx.Path()+"/", http.StatusTemporaryRedirect) + return + } + 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) + return + } + if o.redirectIfExists != "" { + ctx.Redirect(o.redirectIfExists, http.StatusTemporaryRedirect) + return + } +} + +func setHeaders(ctx *context.Context, header http.Header) { + ctx.RespWriter.Header().Set("ETag", header.Get("ETag")) + ctx.RespWriter.Header().Set("Content-Type", header.Get("Content-Type")) +} + +func writeResponse(ctx *context.Context, reader io.Reader) { + ctx.RespWriter.WriteHeader(ctx.StatusCode) + if reader != nil { + _, err := io.Copy(ctx.RespWriter, reader) + if err != nil { + log.Error().Err(err).Msgf("Couldn't write body for %q", ctx.Path()) + html.ReturnErrorPage(ctx, "", http.StatusInternalServerError) + } + } +} diff --git a/server/upstream/upstream.gov1 b/server/upstream/upstream.gov1 new file mode 100644 index 0000000..c15345d --- /dev/null +++ b/server/upstream/upstream.gov1 @@ -0,0 +1,220 @@ +package upstream + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" + + "pages-server/html" + "pages-server/server/cache" + "pages-server/server/context" + "pages-server/server/gitea" +) + +const ( + headerLastModified = "Last-Modified" + headerIfModifiedSince = "If-Modified-Since" + + rawMime = "text/plain; charset=utf-8" +) + +// upstreamIndexPages lists pages that may be considered as index pages for directories. +var upstreamIndexPages = []string{ + "index.html", +} + +// upstreamNotFoundPages lists pages that may be considered as custom 404 Not Found pages. +var upstreamNotFoundPages = []string{ + "404.html", +} + +// Options provides various options for the upstream request. +type Options struct { + TargetOwner string + TargetRepo string + TargetBranch string + TargetPath string + + // Used for debugging purposes. + Host string + + TryIndexPages bool + BranchTimestamp time.Time + // internal + appendTrailingSlash bool + redirectIfExists string + + ServeRaw bool +} + +// 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 { + log := log.With().Strs("upstream", []string{o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath}).Logger() + + log.Debug().Msg("Start") + + if o.TargetOwner == "" || o.TargetRepo == "" { + html.ReturnErrorPage(ctx, "forge client: either repo owner or name info is missing", http.StatusBadRequest) + return true + } + + // Check if the branch exists and when it was modified + if o.BranchTimestamp.IsZero() { + branchExist, err := o.GetBranchTimestamp(giteaClient) + // handle 404 + if err != nil && errors.Is(err, gitea.ErrorNotFound) || !branchExist { + html.ReturnErrorPage(ctx, + fmt.Sprintf("branch %q for %s/%s not found", o.TargetBranch, o.TargetOwner, o.TargetRepo), + http.StatusNotFound) + return true + } + + // handle unexpected errors + if err != nil { + html.ReturnErrorPage(ctx, + fmt.Sprintf("could not get timestamp of branch %q: '%v'", o.TargetBranch, err), + http.StatusFailedDependency) + return true + } + } + + // Check if the browser has a cached version + if ctx.Response() != nil { + if ifModifiedSince, err := time.Parse(time.RFC1123, ctx.Response().Header.Get(headerIfModifiedSince)); err == nil { + if ifModifiedSince.After(o.BranchTimestamp) { + ctx.RespWriter.WriteHeader(http.StatusNotModified) + log.Trace().Msg("check response against last modified: valid") + return true + } + } + log.Trace().Msg("check response against last modified: outdated") + } + + log.Debug().Msg("Preparing") + + reader, header, statusCode, err := giteaClient.ServeRawContent(o.TargetOwner, o.TargetRepo, o.TargetBranch, o.TargetPath) + if reader != nil { + defer reader.Close() + } + + log.Debug().Msg("Aquisting") + + // Handle not found error + if err != nil && errors.Is(err, gitea.ErrorNotFound) { + log.Debug().Msg("Handling not found error") + // Get and match redirects + redirects := o.getRedirects(giteaClient, redirectsCache) + if o.matchRedirects(ctx, giteaClient, redirects, redirectsCache) { + log.Trace().Msg("redirect") + return true + } + + if o.TryIndexPages { + log.Trace().Msg("try index page") + // copy the o struct & try if an index page exists + optionsForIndexPages := *o + optionsForIndexPages.TryIndexPages = false + optionsForIndexPages.appendTrailingSlash = true + for _, indexPage := range upstreamIndexPages { + optionsForIndexPages.TargetPath = strings.TrimSuffix(o.TargetPath, "/") + "/" + indexPage + if optionsForIndexPages.Upstream(ctx, giteaClient, redirectsCache) { + return true + } + } + log.Trace().Msg("try html file with path name") + // compatibility fix for GitHub Pages (/example → /example.html) + optionsForIndexPages.appendTrailingSlash = false + optionsForIndexPages.redirectIfExists = strings.TrimSuffix(ctx.Path(), "/") + ".html" + optionsForIndexPages.TargetPath = o.TargetPath + ".html" + if optionsForIndexPages.Upstream(ctx, giteaClient, redirectsCache) { + return true + } + } + + log.Trace().Msg("not found") + + ctx.StatusCode = http.StatusNotFound + if o.TryIndexPages { + log.Trace().Msg("try not found page") + // copy the o struct & try if a not found page exists + optionsForNotFoundPages := *o + optionsForNotFoundPages.TryIndexPages = false + optionsForNotFoundPages.appendTrailingSlash = false + for _, notFoundPage := range upstreamNotFoundPages { + optionsForNotFoundPages.TargetPath = "/" + notFoundPage + if optionsForNotFoundPages.Upstream(ctx, giteaClient, redirectsCache) { + return true + } + } + log.Trace().Msg("not found page missing") + } + + return false + } + + // handle unexpected client errors + if err != nil || reader == nil || statusCode != http.StatusOK { + log.Debug().Msg("Handling error") + var msg string + + if err != nil { + msg = "forge client: returned unexpected error" + log.Error().Err(err).Msg(msg) + msg = fmt.Sprintf("%s: '%v'", msg, err) + } + if reader == nil { + msg = "forge client: returned no reader" + log.Error().Msg(msg) + } + if statusCode != http.StatusOK { + msg = fmt.Sprintf("forge client: couldn't fetch contents: %d - %s", statusCode, http.StatusText(statusCode)) + log.Error().Msg(msg) + } + + html.ReturnErrorPage(ctx, msg, http.StatusInternalServerError) + return true + } + + // 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 + if o.appendTrailingSlash && !strings.HasSuffix(ctx.Path(), "/") { + log.Trace().Msg("append trailing slash and redirect") + ctx.Redirect(ctx.Path()+"/", http.StatusTemporaryRedirect) + return true + } + 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) + return true + } + if o.redirectIfExists != "" { + ctx.Redirect(o.redirectIfExists, http.StatusTemporaryRedirect) + return true + } + + // Set ETag & MIME + o.setHeader(ctx, header) + + log.Debug().Msg("Prepare response") + + ctx.RespWriter.WriteHeader(ctx.StatusCode) + + // Write the response body to the original request + if reader != nil { + _, err := io.Copy(ctx.RespWriter, reader) + if err != nil { + log.Error().Err(err).Msgf("Couldn't write body for %q", o.TargetPath) + html.ReturnErrorPage(ctx, "", http.StatusInternalServerError) + return true + } + } + + log.Debug().Msg("Sending response") + + return true +} diff --git a/server/utils/utils.go b/server/utils/utils.go new file mode 100644 index 0000000..91ed359 --- /dev/null +++ b/server/utils/utils.go @@ -0,0 +1,27 @@ +package utils + +import ( + "net/url" + "path" + "strings" +) + +func TrimHostPort(host string) string { + i := strings.IndexByte(host, ':') + if i >= 0 { + return host[:i] + } + return host +} + +func CleanPath(uriPath string) string { + unescapedPath, _ := url.PathUnescape(uriPath) + cleanedPath := path.Join("/", unescapedPath) + + // If the path refers to a directory, add a trailing slash. + if !strings.HasSuffix(cleanedPath, "/") && (strings.HasSuffix(unescapedPath, "/") || strings.HasSuffix(unescapedPath, "/.") || strings.HasSuffix(unescapedPath, "/..")) { + cleanedPath += "/" + } + + return cleanedPath +} diff --git a/server/utils/utils_test.go b/server/utils/utils_test.go new file mode 100644 index 0000000..b8fcea9 --- /dev/null +++ b/server/utils/utils_test.go @@ -0,0 +1,69 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTrimHostPort(t *testing.T) { + assert.EqualValues(t, "aa", TrimHostPort("aa")) + assert.EqualValues(t, "", TrimHostPort(":")) + assert.EqualValues(t, "example.com", TrimHostPort("example.com:80")) +} + +// TestCleanPath is mostly copied from fasthttp, to keep the behaviour we had before migrating away from it. +// Source (MIT licensed): https://github.com/valyala/fasthttp/blob/v1.48.0/uri_test.go#L154 +// Copyright (c) 2015-present Aliaksandr Valialkin, VertaMedia, Kirill Danshin, Erik Dubbelboer, FastHTTP Authors +func TestCleanPath(t *testing.T) { + // double slash + testURIPathNormalize(t, "/aa//bb", "/aa/bb") + + // triple slash + testURIPathNormalize(t, "/x///y/", "/x/y/") + + // multi slashes + testURIPathNormalize(t, "/abc//de///fg////", "/abc/de/fg/") + + // encoded slashes + testURIPathNormalize(t, "/xxxx%2fyyy%2f%2F%2F", "/xxxx/yyy/") + + // dotdot + testURIPathNormalize(t, "/aaa/..", "/") + + // dotdot with trailing slash + testURIPathNormalize(t, "/xxx/yyy/../", "/xxx/") + + // multi dotdots + testURIPathNormalize(t, "/aaa/bbb/ccc/../../ddd", "/aaa/ddd") + + // dotdots separated by other data + testURIPathNormalize(t, "/a/b/../c/d/../e/..", "/a/c/") + + // too many dotdots + testURIPathNormalize(t, "/aaa/../../../../xxx", "/xxx") + testURIPathNormalize(t, "/../../../../../..", "/") + testURIPathNormalize(t, "/../../../../../../", "/") + + // encoded dotdots + testURIPathNormalize(t, "/aaa%2Fbbb%2F%2E.%2Fxxx", "/aaa/xxx") + + // double slash with dotdots + testURIPathNormalize(t, "/aaa////..//b", "/b") + + // fake dotdot + testURIPathNormalize(t, "/aaa/..bbb/ccc/..", "/aaa/..bbb/") + + // single dot + testURIPathNormalize(t, "/a/./b/././c/./d.html", "/a/b/c/d.html") + testURIPathNormalize(t, "./foo/", "/foo/") + testURIPathNormalize(t, "./../.././../../aaa/bbb/../../../././../", "/") + testURIPathNormalize(t, "./a/./.././../b/./foo.html", "/b/foo.html") +} + +func testURIPathNormalize(t *testing.T, requestURI, expectedPath string) { + cleanedPath := CleanPath(requestURI) + if cleanedPath != expectedPath { + t.Fatalf("Unexpected path %q. Expected %q. requestURI=%q", cleanedPath, expectedPath, requestURI) + } +} diff --git a/server/version/version.go b/server/version/version.go new file mode 100644 index 0000000..aa2cbb5 --- /dev/null +++ b/server/version/version.go @@ -0,0 +1,3 @@ +package version + +var Version string = "dev"