Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e7ab4c8e | ||
|
|
ba4063cb44 | ||
|
|
c947a06d48 | ||
|
|
5f439cf1d5 | ||
|
|
7de51ad2ef | ||
|
|
389189afcf | ||
|
|
6f40926241 | ||
|
|
2640aaee9e | ||
|
|
250e8ad925 | ||
|
|
4793c1cd2c | ||
|
|
23be25d84a | ||
|
|
4d5dae20e2 | ||
|
|
a82dc8d3f1 | ||
|
|
e3d1d0e23e | ||
|
|
49bf83b09c | ||
|
|
0b651dddd4 | ||
|
|
b6975af63c | ||
|
|
4fca268dc6 | ||
|
|
a0879b2336 | ||
|
|
7c312da199 | ||
|
|
a5b2fe3cfc | ||
|
|
7ee3a0da48 | ||
|
|
4aaba0fb61 | ||
|
|
fb7b6c2cdd | ||
|
|
123b9328d9 | ||
|
|
2bfad021e3 | ||
|
|
4c9ef37cf1 | ||
|
|
ff77f41a3a | ||
|
|
1bb002348a | ||
|
|
fc4e10573c | ||
|
|
a91e322d5c | ||
|
|
931f2db4e3 | ||
|
|
b8f6dc5f91 | ||
|
|
b03dc366f9 | ||
|
|
36062de0e7 | ||
|
|
90766d6637 | ||
|
|
a1dbb2cb92 | ||
|
|
d370b78613 | ||
|
|
439ccfa83c | ||
|
|
29627f4bf0 | ||
|
|
164eb89c60 | ||
|
|
09110c792f | ||
|
|
3f5b3cdd98 | ||
|
|
43fccf3bc9 | ||
|
|
1f34984dab | ||
|
|
455d770899 | ||
|
|
bf67c251c5 | ||
|
|
cebba4cee4 | ||
|
|
35a389cb75 | ||
|
|
a725d990ed | ||
|
|
fdfce6495c | ||
|
|
8801903d01 | ||
|
|
8bcfd9e456 | ||
|
|
5b37a583ce | ||
|
|
29b5f88a1c | ||
|
|
287aef4dfb | ||
|
|
d06aa6771c | ||
|
|
a54d2d2756 | ||
|
|
e575066ea9 | ||
|
|
2544d80f98 | ||
|
|
5c0798fe10 | ||
|
|
76d0cc2cf6 | ||
|
|
51b6a7e890 | ||
|
|
8c5e82bd16 | ||
|
|
95f59213e5 | ||
|
|
503f5cacb0 | ||
|
|
d1f8cf412b | ||
|
|
db9d8703ef | ||
|
|
8f61ec691b | ||
|
|
07f6b9a0a0 | ||
|
|
98e4f03091 | ||
|
|
f8f2becb4b | ||
|
|
6e7820d2b7 | ||
|
|
3e63d72852 | ||
|
|
a8dd527c45 | ||
|
|
e62b3c830d | ||
|
|
b1502801fa | ||
|
|
2e3a680ca4 | ||
|
|
d8b7f7530c | ||
|
|
b33025a76f | ||
|
|
c48254a994 | ||
|
|
3fc5d6b970 | ||
|
|
380aa3c828 | ||
|
|
b6bc8ba675 | ||
|
|
c5121fd402 | ||
|
|
fff9127976 | ||
|
|
406df022ba | ||
|
|
6840a46ac9 | ||
|
|
6a50ffd88a | ||
|
|
f7dbdae26c | ||
|
|
f4b236c66a | ||
|
|
17365f4304 | ||
|
|
0b6eea68a1 | ||
|
|
b19e6269c1 | ||
|
|
bb6d1450f1 | ||
|
|
8318f89dde | ||
|
|
3e18681ca7 | ||
|
|
11d8a978a2 | ||
|
|
d7fe27cb97 | ||
|
|
7fee12a978 | ||
|
|
fc3d93c398 | ||
|
|
4baf0f4260 | ||
|
|
b72030114c | ||
|
|
8626e4181f | ||
|
|
d274724c08 | ||
|
|
f5ccfaa0e1 | ||
|
|
c8832e708c | ||
|
|
07c7926453 | ||
|
|
34ed97b7a6 | ||
|
|
0868b70fa9 | ||
|
|
589e956ade | ||
|
|
a21044738d | ||
|
|
929c06d957 | ||
|
|
198efddb54 | ||
|
|
39408dffe8 | ||
|
|
6961dc24e5 | ||
|
|
e8b9d70061 | ||
|
|
e8c106f06b | ||
|
|
54b7b3fdd4 |
+3
-5
@@ -10,10 +10,6 @@
|
||||
/frontend/node_modules/
|
||||
/frontend/apps/remark42/node_modules/
|
||||
/frontend/apps/remark42/public/
|
||||
# e2e tests arficats
|
||||
/frontend/e2e/playwright-report/
|
||||
/frontend/e2e/playwright/.cache/
|
||||
/frontend/e2e/test-results/
|
||||
|
||||
# source files
|
||||
docker-compose.yml
|
||||
@@ -36,4 +32,6 @@ debug.test
|
||||
*.test
|
||||
remark42
|
||||
/backend/var/
|
||||
/playwright-report/
|
||||
|
||||
# go e2e suite, never built into the image
|
||||
/e2e/
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
# review when someone opens a pull request.
|
||||
|
||||
* @umputun
|
||||
frontend/* @akellbl4 @Mavrin
|
||||
frontend/* @umputun @akellbl4 @Mavrin
|
||||
|
||||
+20
-26
@@ -4,6 +4,11 @@
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
|
||||
# npm updates are switched off entirely. open-pull-requests-limit bounds version
|
||||
# updates only, so the ignore entries below are what also stops security updates;
|
||||
# removing the npm entries would not work, as security updates come from alerts
|
||||
# rather than from this file.
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
@@ -20,29 +25,18 @@ updates:
|
||||
groups:
|
||||
"Go modules updates":
|
||||
dependency-type: "production"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/e2e"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"Go modules updates":
|
||||
dependency-type: "production"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend/packages/api"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend/e2e"
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
@@ -53,6 +47,8 @@ updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend/apps/remark42"
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
@@ -60,13 +56,11 @@ updates:
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/site"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
"Site image updates":
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -7,13 +7,18 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/ci-backend.yml"
|
||||
- "backend/**"
|
||||
- "Dockerfile"
|
||||
- "docker-init.sh"
|
||||
- ".dockerignore"
|
||||
- "!backend/scripts/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
paths:
|
||||
- ".github/workflows/ci-backend.yml"
|
||||
- "backend/**"
|
||||
- "Dockerfile"
|
||||
- "docker-init.sh"
|
||||
- ".dockerignore"
|
||||
- "!backend/scripts/**"
|
||||
- "!**.md"
|
||||
|
||||
@@ -25,7 +30,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -35,14 +40,15 @@ jobs:
|
||||
DEBUG: ${{secrets.DEBUG}}
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
cache-dependency-path: backend
|
||||
|
||||
- name: test and build backend
|
||||
run: |
|
||||
go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
|
||||
go test -race -timeout=300s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
|
||||
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
|
||||
go build -race ./...
|
||||
working-directory: backend/app
|
||||
@@ -60,13 +66,13 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: "v2.10.1"
|
||||
version: "v2.13.1"
|
||||
working-directory: backend/app
|
||||
|
||||
- name: golangci-lint on example directory
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: "v2.10.1"
|
||||
version: "v2.13.1"
|
||||
args: --config ../../.golangci.yml
|
||||
working-directory: backend/_example/memory_store
|
||||
|
||||
@@ -77,3 +83,35 @@ jobs:
|
||||
working-directory: backend
|
||||
env:
|
||||
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
vulncheck:
|
||||
name: Vulnerability scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
# both go.sum files so the cache key covers the main and example modules scanned below
|
||||
cache-dependency-path: |
|
||||
backend/go.sum
|
||||
backend/_example/memory_store/go.sum
|
||||
|
||||
- name: govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@v1.5.0
|
||||
govulncheck ./...
|
||||
(cd _example/memory_store && govulncheck ./...)
|
||||
working-directory: backend
|
||||
env:
|
||||
# ignore the committed vendor dirs and resolve modules from the cache so
|
||||
# both the main module and the nested example module scan consistently
|
||||
GOFLAGS: "-mod=readonly"
|
||||
|
||||
@@ -23,21 +23,13 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: expose GitHub Actions cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
@@ -46,21 +38,26 @@ jobs:
|
||||
docker system prune -af
|
||||
|
||||
- name: build docker image without pushing
|
||||
run: |
|
||||
docker buildx build --load \
|
||||
--cache-from type=local,src=/tmp/.buildx-cache \
|
||||
--cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
||||
--platform linux/amd64 .
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
cache-from: type=gha,scope=main
|
||||
cache-to: type=gha,scope=main,mode=max,ignore-error=true
|
||||
build-args: |
|
||||
SKIP_BACKEND_TEST=true
|
||||
SKIP_FRONTEND_TEST=true
|
||||
|
||||
- name: build example docker image without pushing
|
||||
run: |
|
||||
docker buildx build --load \
|
||||
--cache-from type=local,src=/tmp/.buildx-cache \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
||||
--platform linux/amd64 -f backend/_example/memory_store/Dockerfile .
|
||||
|
||||
- name: rotate cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache || true
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: backend/_example/memory_store/Dockerfile
|
||||
platforms: linux/amd64
|
||||
load: true
|
||||
cache-from: type=gha,scope=example
|
||||
cache-to: type=gha,scope=example,mode=max,ignore-error=true
|
||||
build-args: |
|
||||
SKIP_BACKEND_TEST=true
|
||||
SKIP_FRONTEND_TEST=true
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: compose
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-compose.yml"
|
||||
- "**compose*.yml"
|
||||
- "**compose*.yaml"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-compose.yml"
|
||||
- "**compose*.yml"
|
||||
- "**compose*.yaml"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Validate compose files
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: validate tracked compose files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
n=0
|
||||
# null-delimited to stay safe with unusual filenames; exclude this
|
||||
# workflow (its name contains "compose") and vendored compose files.
|
||||
# filenames are not echoed as workflow commands to avoid log-command injection
|
||||
while IFS= read -r -d '' f; do
|
||||
docker compose -f "$f" config --quiet
|
||||
n=$((n + 1))
|
||||
done < <(git ls-files -z '*compose*.yml' '*compose*.yaml' ':!:*/vendor/*' ':!:.github/*')
|
||||
if [ "$n" -eq 0 ]; then
|
||||
echo "no compose files found" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "validated $n compose file(s)"
|
||||
@@ -0,0 +1,37 @@
|
||||
name: docs versions
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-docs-versions.yml"
|
||||
- "scripts/check-documented-versions.sh"
|
||||
- "site/content/docs/getting-started/installation/index.md"
|
||||
- "backend/go.mod"
|
||||
- "frontend/apps/remark42/package.json"
|
||||
- "frontend/.nvmrc"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-docs-versions.yml"
|
||||
- "scripts/check-documented-versions.sh"
|
||||
- "site/content/docs/getting-started/installation/index.md"
|
||||
- "backend/go.mod"
|
||||
- "frontend/apps/remark42/package.json"
|
||||
- "frontend/.nvmrc"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Documented versions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check documented versions against the repository
|
||||
run: ./scripts/check-documented-versions.sh
|
||||
@@ -1,131 +0,0 @@
|
||||
name: "@remark42/api"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend-api.yml"
|
||||
- "frontend/packages/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend-api.yml"
|
||||
- "frontend/packages/**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [ 16 ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm type-check:api
|
||||
working-directory: ./frontend
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [ 16 ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint:api
|
||||
working-directory: ./frontend/
|
||||
|
||||
test:
|
||||
name: Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [ 16 ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Test & Coverage
|
||||
run: pnpm coverage:api
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
working-directory: ./frontend
|
||||
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
|
||||
@@ -6,12 +6,12 @@ on:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
@@ -22,30 +22,30 @@ jobs:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
node: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Translations check
|
||||
run: pnpm translation-check
|
||||
@@ -58,30 +58,30 @@ jobs:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
node: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm type-check
|
||||
@@ -94,30 +94,30 @@ jobs:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
node: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
@@ -134,14 +134,14 @@ jobs:
|
||||
CI_JOB_NUMBER: 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: Check bundle size
|
||||
@@ -158,37 +158,37 @@ jobs:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16]
|
||||
node: [24]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Test & Coverage
|
||||
run: pnpm coverage
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
@@ -9,16 +9,47 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
- "!**/CLAUDE.md"
|
||||
- "!site/README.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
- "!**/CLAUDE.md"
|
||||
- "!site/README.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Build site image (pull request)
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up docker buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: build image without pushing
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./site
|
||||
load: true
|
||||
push: false
|
||||
cache-from: |
|
||||
type=gha,scope=site-pr
|
||||
type=gha,scope=site-linux/amd64
|
||||
cache-to: type=gha,scope=site-pr,mode=max,ignore-error=true
|
||||
|
||||
build:
|
||||
name: Build site image (${{ matrix.platform }})
|
||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
|
||||
@@ -39,7 +70,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -60,7 +91,7 @@ jobs:
|
||||
context: ./site
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=site-${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=site-${{ matrix.platform }},mode=max
|
||||
cache-to: type=gha,scope=site-${{ matrix.platform }},mode=max,ignore-error=true
|
||||
outputs: type=image,name=ghcr.io/umputun/remark42-site,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: export digest
|
||||
|
||||
@@ -2,7 +2,7 @@ name: docker
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [backend]
|
||||
workflows: [backend, frontend]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
+101
-18
@@ -5,38 +5,121 @@ on:
|
||||
branches: [master]
|
||||
paths:
|
||||
- ".github/workflows/e2e-tests.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/e2e/**"
|
||||
- "frontend/Dockerfile.e2e"
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "e2e/**"
|
||||
- "compose-e2e-test.yml"
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- ".github/workflows/e2e-tests.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/e2e/**"
|
||||
- "frontend/Dockerfile.e2e"
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "e2e/**"
|
||||
- "compose-e2e-test.yml"
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
timeout-minutes: 60
|
||||
# cheap gate: catches a compile break or a lint regression in the build-tagged suite
|
||||
# without paying for the docker build and the browser download
|
||||
vet:
|
||||
name: Vet
|
||||
timeout-minutes: 10
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build & run containers
|
||||
id: tests
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ./playwright-report/
|
||||
go-version-file: e2e/go.mod
|
||||
cache-dependency-path: e2e/go.sum
|
||||
|
||||
- name: Vet
|
||||
run: cd e2e && go vet -tags=e2e ./...
|
||||
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: v2.13.1
|
||||
working-directory: e2e
|
||||
args: --build-tags=e2e --config ../backend/.golangci.yml
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
needs: vet
|
||||
# generous against the docker build plus one 8m go test: a job cancelled on timeout skips
|
||||
# its own failure steps, so the run would end with neither logs nor traces
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: e2e/go.mod
|
||||
cache-dependency-path: e2e/go.sum
|
||||
|
||||
# two directories: the driver (node plus the npm package) and the browser builds,
|
||||
# which include firefox and webkit for the rendering tests
|
||||
- name: Cache playwright driver and browsers
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
~/.cache/ms-playwright-go
|
||||
key: playwright-${{ hashFiles('e2e/go.sum') }}
|
||||
restore-keys: playwright-
|
||||
|
||||
# E2E_STAMP is what the suite compares the running stack against, so a stack started here
|
||||
# has to carry the same value `make e2e-up` and the suite itself would give it
|
||||
- name: Build & start the stack
|
||||
run: |
|
||||
./e2e/tls/generate.sh
|
||||
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 E2E_STAMP=$(./e2e/stamp.sh) \
|
||||
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
|
||||
|
||||
# no retry: a failure here is evidence about a suite too young to have a flake rate,
|
||||
# and a rerun is how an intermittent regression becomes invisible. revisit when there
|
||||
# are failures on record to look at
|
||||
- name: Run e2e
|
||||
# stamps this run's comment threads with the CI run, so a thread url in a trace or a
|
||||
# log names the run it came from
|
||||
env:
|
||||
E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
# 20m, matching the Makefile. the suite runs about four minutes on a laptop and a runner
|
||||
# is slower, so a tighter budget turns a loaded runner into a timeout panic instead of a
|
||||
# readable failure. the job's own timeout above is what bounds a wedged run
|
||||
run: cd e2e && go test -tags=e2e -count 1 -timeout 20m -v ./...
|
||||
|
||||
- name: Server logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f compose-e2e-test.yml logs --tail=200
|
||||
|
||||
- name: Upload browser traces
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-traces
|
||||
path: e2e/traces/
|
||||
retention-days: 30
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -12,10 +12,10 @@ on:
|
||||
- "scripts/**"
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "!backend/**.md"
|
||||
- "!frontend/**.md"
|
||||
- "README.md"
|
||||
- "LICENSE"
|
||||
- "CLAUDE.md"
|
||||
- "site/src/docs/getting-started/installation/index.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -24,33 +24,34 @@ jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||
|
||||
- name: test and build backend
|
||||
run: |
|
||||
go test -race -timeout=120s ./...
|
||||
go test -race -timeout=300s ./...
|
||||
go build -race ./...
|
||||
working-directory: backend/app
|
||||
env:
|
||||
@@ -66,7 +67,7 @@ jobs:
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
working-directory: frontend/apps/remark42
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
@@ -74,7 +75,7 @@ jobs:
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
pnpm test -- --runInBand
|
||||
pnpm test --runInBand
|
||||
working-directory: frontend/apps/remark42
|
||||
env:
|
||||
CI: "true"
|
||||
@@ -99,33 +100,34 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: "1.25"
|
||||
check-latest: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: install pnpm
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
with:
|
||||
version: 8
|
||||
version: 10.10.0
|
||||
run_install: false
|
||||
|
||||
- name: install node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
working-directory: frontend/apps/remark42
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
|
||||
+6
-1
@@ -25,8 +25,13 @@ compose-private-frontend.yml
|
||||
compose-private.yml
|
||||
/backend/_example/*/vendor
|
||||
http-client.env.json
|
||||
/playwright-report/
|
||||
/backend/app/cmd/var
|
||||
|
||||
# ralphex progress logs
|
||||
.ralphex/progress/
|
||||
|
||||
# traces from failed e2e runs
|
||||
/e2e/traces/
|
||||
|
||||
# self-signed certificate for the e2e https services, made by e2e/tls/generate.sh
|
||||
/e2e/tls/*.pem
|
||||
|
||||
@@ -26,26 +26,17 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
- "386"
|
||||
- arm
|
||||
goarm:
|
||||
- "7"
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: "386"
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: "386"
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
ldflags:
|
||||
- -s -w -X main.revision={{ .Tag }}-{{ .ShortCommit }}-{{ trimsuffix (replace (replace .CommitDate "-" "") ":" "") "Z" }}
|
||||
|
||||
|
||||
@@ -6,19 +6,40 @@
|
||||
- Build: `make backend`
|
||||
- Race test: `make race_test`
|
||||
- **Backend Testing**:
|
||||
- Run all tests: `cd backend/app && go test -timeout=60s -count 1 ./...`
|
||||
- Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`
|
||||
- Run single test: `cd backend/app && go test -run TestName ./path/to/package`
|
||||
- **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`
|
||||
- **Frontend**:
|
||||
- Development: `cd frontend && pnpm dev:app`
|
||||
- Tests: `cd frontend && pnpm test`
|
||||
- Development: `cd frontend/apps/remark42 && pnpm dev`
|
||||
- Tests: `cd frontend/apps/remark42 && pnpm test`
|
||||
- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.
|
||||
- **Lint**:
|
||||
- Backend: `cd backend && golangci-lint run`
|
||||
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
|
||||
- Frontend: `cd frontend && pnpm lint`
|
||||
- Frontend: `cd frontend/apps/remark42 && pnpm lint`
|
||||
- **Before committing**: Always run tests and linter on both main backend AND examples
|
||||
- **Dependency Updates**:
|
||||
- When updating Go modules in `backend/`, also run `go mod tidy` (and `go mod vendor`) in `backend/_example/memory_store` to keep indirect deps in sync. The example module replaces `github.com/umputun/remark42/backend` with `../../` so stale indirect deps there will break the example build.
|
||||
- **Go module changes**:
|
||||
- **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.
|
||||
- Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.
|
||||
- The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.
|
||||
- This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.
|
||||
|
||||
|
||||
## Backend Test Determinism
|
||||
|
||||
Backend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.
|
||||
|
||||
- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.
|
||||
- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.
|
||||
- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.
|
||||
- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.
|
||||
- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.
|
||||
- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.
|
||||
- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen("tcp", ":0")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.
|
||||
- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.
|
||||
- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.
|
||||
|
||||
`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.
|
||||
|
||||
## Release Procedure
|
||||
|
||||
@@ -39,7 +60,22 @@ git push origin backend/vX.Y.Z
|
||||
|
||||
GoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.
|
||||
|
||||
For local artifact runs, install GoReleaser, Go 1.25, Node 16+, PNPM 8, and Perl, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.
|
||||
For local artifact runs, install GoReleaser, Go 1.25, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.
|
||||
|
||||
## Milestones and Issue Labels
|
||||
|
||||
**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.
|
||||
- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title="vX.Y.Z"`).
|
||||
- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.
|
||||
- Find unassigned: `gh pr list --state merged --search "no:milestone"`, `gh issue list --state closed --search "no:milestone"`. Assign with `gh pr edit N --milestone "vX.Y.Z"` / `gh issue edit N --milestone "vX.Y.Z"`.
|
||||
|
||||
**Issue labels** — classify each issue with a type and an area (add priority when relevant):
|
||||
- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`
|
||||
- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`
|
||||
- Priority: `important`, `minor`, `some day`
|
||||
- Contribution: `help wanted`, `good-first-issue`
|
||||
- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`
|
||||
- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`
|
||||
|
||||
## Code Style
|
||||
- **Backend**: Formatting with golangci-lint, strict error handling
|
||||
@@ -48,7 +84,7 @@ For local artifact runs, install GoReleaser, Go 1.25, Node 16+, PNPM 8, and Perl
|
||||
- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx
|
||||
|
||||
## Key Backend Packages
|
||||
- **Web/API**: `github.com/go-chi/chi/v5`, `github.com/go-pkgz/rest`
|
||||
- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`
|
||||
- **Auth**: `github.com/go-pkgz/auth/v2`
|
||||
- **Logging**: `github.com/go-pkgz/lgr`
|
||||
- **Testing**: `github.com/stretchr/testify`
|
||||
@@ -57,3 +93,8 @@ For local artifact runs, install GoReleaser, Go 1.25, Node 16+, PNPM 8, and Perl
|
||||
## Repository Structure
|
||||
- Backend: Go server using BoltDB for storage
|
||||
- Frontend: Preact/Redux-based UI with iframe embedding
|
||||
- `/web` is served from two sources, in lookup order: the frontend build output
|
||||
(`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),
|
||||
then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler
|
||||
does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes
|
||||
through webpack. A name present in both is served from the frontend build.
|
||||
|
||||
+7
-6
@@ -1,17 +1,19 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16.20-alpine AS frontend-deps
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-deps
|
||||
|
||||
ARG SKIP_FRONTEND_TEST
|
||||
ARG SKIP_FRONTEND_BUILD
|
||||
# the manifest's prepare script installs husky hooks, which needs a git repository the build
|
||||
# context does not have. husky itself skips on CI, and this is the same flag the build stage sets
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /srv/frontend/
|
||||
WORKDIR /srv/frontend/apps/remark42/
|
||||
|
||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/
|
||||
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/
|
||||
COPY ./frontend/apps/remark42/package.json ./frontend/apps/remark42/pnpm-lock.yaml /srv/frontend/apps/remark42/
|
||||
|
||||
RUN \
|
||||
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
|
||||
apk add --no-cache --update git && \
|
||||
npm i -g pnpm@8; \
|
||||
npm i -g pnpm@10.10.0; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
|
||||
@@ -60,7 +62,6 @@ RUN apk --no-cache add gcc libc-dev
|
||||
ADD backend /build/backend
|
||||
# to embed the frontend files statically into Remark42 binary
|
||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
|
||||
RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
|
||||
WORKDIR /build/backend
|
||||
|
||||
RUN echo go version: `go version`
|
||||
|
||||
@@ -17,7 +17,7 @@ docker:
|
||||
dockerx:
|
||||
docker buildx build --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) --build-arg CI=true \
|
||||
--build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true \
|
||||
--progress=plain --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
--progress=plain --platform linux/amd64,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
|
||||
|
||||
release:
|
||||
@@ -26,7 +26,7 @@ release:
|
||||
goreleaser release --snapshot --clean --skip=publish
|
||||
|
||||
race_test:
|
||||
cd backend/app && go test -race -timeout=60s -count 1 ./...
|
||||
cd backend/app && go test -race -timeout=300s -count 1 ./...
|
||||
|
||||
backend:
|
||||
docker compose -f compose-dev-backend.yml build
|
||||
@@ -39,7 +39,21 @@ rundev:
|
||||
docker compose -f compose-private.yml build
|
||||
docker compose -f compose-private.yml up
|
||||
|
||||
e2e:
|
||||
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
# stamped the same way the suite stamps a stack it starts itself, so one brought up here is
|
||||
# accepted instead of rejected as belonging to another checkout
|
||||
e2e-up:
|
||||
./e2e/tls/generate.sh
|
||||
E2E_STAMP=$$(./e2e/stamp.sh) docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
|
||||
|
||||
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e
|
||||
e2e-down:
|
||||
docker compose -f compose-e2e-test.yml down -v
|
||||
|
||||
# the suite brings the stack up itself when it finds none, so e2e-up is only worth running
|
||||
# to keep the containers between invocations
|
||||
e2e:
|
||||
cd e2e && go test -tags=e2e -count 1 -timeout 20m ./...
|
||||
|
||||
e2e-ui:
|
||||
cd e2e && E2E_HEADLESS=false E2E_KEEP=1 go test -tags=e2e -count 1 -v -timeout 20m ./...
|
||||
|
||||
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e e2e-up e2e-down e2e-ui
|
||||
|
||||
@@ -20,9 +20,6 @@ linters:
|
||||
- unparam
|
||||
- unused
|
||||
settings:
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
gosec:
|
||||
excludes:
|
||||
- G117 # false positive: struct field name matches "secret" pattern
|
||||
@@ -38,8 +35,6 @@ linters:
|
||||
govet:
|
||||
enable:
|
||||
- shadow
|
||||
lll:
|
||||
line-length: 140
|
||||
misspell:
|
||||
locale: US
|
||||
exclusions:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
../site/src/docs/contributing/backend/index.md
|
||||
../site/content/docs/contributing/backend/index.md
|
||||
@@ -3,38 +3,32 @@ module github.com/umputun/remark42/memory_store
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-pkgz/jrpc v0.4.0
|
||||
github.com/go-pkgz/lgr v0.12.3
|
||||
github.com/go-pkgz/jrpc v0.4.2
|
||||
github.com/go-pkgz/lgr v0.12.4
|
||||
github.com/jessevdk/go-flags v1.6.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/umputun/remark42/backend v1.1000.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.12.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/go-pkgz/rest v1.21.0 // indirect
|
||||
github.com/go-pkgz/routegroup v1.6.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
|
||||
github.com/go-pkgz/rest v1.24.0 // indirect
|
||||
github.com/go-pkgz/routegroup v1.6.1 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
go.etcd.io/bbolt v1.5.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/umputun/remark42/backend v1.1000.0 => ../../
|
||||
|
||||
@@ -4,133 +4,49 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY=
|
||||
github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo=
|
||||
github.com/go-pkgz/lgr v0.12.3 h1:QDug7kRkEsuQtruT9fNF5PVT2kZUqCDPc4GmsgS3fP8=
|
||||
github.com/go-pkgz/lgr v0.12.3/go.mod h1:lpCDgVvCIxBHZp8+sGCj9MPctIzKZyZ3QdE19ddqd54=
|
||||
github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4=
|
||||
github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE=
|
||||
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
|
||||
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/go-pkgz/jrpc v0.4.2 h1:gY5mmxp9/dFd1WsHybVZILQpF11YNWWS3Ga+Pc5aIAU=
|
||||
github.com/go-pkgz/jrpc v0.4.2/go.mod h1:ZtnMpIXYmwXh6W44XO2lE5Lh5J+6KeeMIvw+vF9xXRQ=
|
||||
github.com/go-pkgz/lgr v0.12.4 h1:lDeQ4BR28ldXrKau6BOjq7A8nHzcXz+MF4xUfV4l1Ok=
|
||||
github.com/go-pkgz/lgr v0.12.4/go.mod h1:Lw6DkNRnCPyX07mqkiUK/p+eA1opq4GKkWfWia64RA8=
|
||||
github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
|
||||
github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
|
||||
github.com/go-pkgz/routegroup v1.6.1 h1:6I/0LabazpZsHAI+jYPeyH/KU2cvZF0bFylUScMNi+Q=
|
||||
github.com/go-pkgz/routegroup v1.6.1/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
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/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
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/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
|
||||
@@ -115,14 +115,17 @@ func TestRPC_imgCleanupHndl(t *testing.T) {
|
||||
assert.Equal(t, 1462, len(img))
|
||||
assert.Equal(t, gopherPNGBytes(), img)
|
||||
|
||||
// wait for image to expire
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
// reset the time to cleanup
|
||||
// age the image past the ttl used below, so the reset that follows is what keeps it on
|
||||
// staging rather than the image simply being young
|
||||
const stagingTTL = 500 * time.Millisecond
|
||||
time.Sleep(stagingTTL + 100*time.Millisecond)
|
||||
|
||||
// reset the time to cleanup, which leaves a full ttl before it could be collected again
|
||||
err = ri.ResetCleanupTimer(id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// cleanup, should not affect the new image
|
||||
err = ri.Cleanup(context.TODO(), time.Millisecond*45)
|
||||
err = ri.Cleanup(context.TODO(), stagingTTL)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load after cleanup should succeed
|
||||
|
||||
@@ -8,7 +8,6 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -20,27 +19,31 @@ import (
|
||||
"github.com/umputun/remark42/memory_store/accessor"
|
||||
)
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||
// collision between concurrently running package test binaries very unlikely
|
||||
func chooseUnusedPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", ":0")
|
||||
require.NoError(t, err, "no free port available")
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
return port
|
||||
}
|
||||
|
||||
func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 3 seconds for server to start before returning it
|
||||
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
||||
// port if it never does
|
||||
func waitForHTTPServerStart(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
client := http.Client{Timeout: time.Second}
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return
|
||||
defer client.CloseIdleConnections()
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return true
|
||||
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
|
||||
}
|
||||
|
||||
func prepTestStore(t *testing.T) (port int, teardown func()) {
|
||||
@@ -61,14 +64,17 @@ func prepTestStore(t *testing.T) (port int, teardown func()) {
|
||||
admRecDisabled.Enabled = false
|
||||
adm.Set("test-site-disabled", admRecDisabled)
|
||||
|
||||
port = chooseRandomUnusedPort()
|
||||
port = chooseUnusedPort(t)
|
||||
go func() {
|
||||
_ = s.Run(port)
|
||||
}()
|
||||
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
return port, func() {
|
||||
// every test client here uses http.DefaultTransport, so their keep-alive connections
|
||||
// sit in one shared pool; Shutdown waits on them and hits its own 5s deadline otherwise
|
||||
http.DefaultTransport.(*http.Transport).CloseIdleConnections()
|
||||
require.NoError(t, s.Shutdown())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -58,7 +57,7 @@ func TestCleanup_IsSpam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCleanup_postsInRange(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r := http.NewServeMux()
|
||||
cleanupRoutes(t, r, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -81,7 +80,7 @@ func TestCleanup_postsInRange(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCleanup_listComments(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r := http.NewServeMux()
|
||||
cleanupRoutes(t, r, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -107,7 +106,7 @@ func TestCleanup_listComments(t *testing.T) {
|
||||
|
||||
func TestCleanup_ExecuteSpam(t *testing.T) {
|
||||
cleaned := cleanedComments{}
|
||||
r := chi.NewRouter()
|
||||
r := http.NewServeMux()
|
||||
cleanupRoutes(t, r, &cleaned)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -126,7 +125,7 @@ func TestCleanup_ExecuteSpam(t *testing.T) {
|
||||
|
||||
func TestCleanup_ExecuteTitle(t *testing.T) {
|
||||
titledComments := cleanedComments{}
|
||||
r := chi.NewRouter()
|
||||
r := http.NewServeMux()
|
||||
cleanupRoutes(t, r, &titledComments)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
@@ -142,7 +141,7 @@ func TestCleanup_ExecuteTitle(t *testing.T) {
|
||||
assert.Equal(t, []string{"/api/v1/admin/title/1", "/api/v1/admin/title/2", "/api/v1/admin/title/3", "/api/v1/admin/title/11"}, titledComments.ids)
|
||||
}
|
||||
|
||||
func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
func cleanupRoutes(t *testing.T, r *http.ServeMux, c *cleanedComments) {
|
||||
r.HandleFunc("/api/v1/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "GET", r.Method)
|
||||
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/jessevdk/go-flags"
|
||||
@@ -133,15 +132,14 @@ func TestImport_ExecuteFailed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_ExecuteTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
fmt.Fprintln(w, "some response")
|
||||
fmt.Fprintln(w, string(body))
|
||||
// hold the response until the client gives up on its own timeout
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -85,12 +85,13 @@ type ServerCommand struct {
|
||||
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
|
||||
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
||||
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
|
||||
TrustedProxies []string `long:"trusted-proxy" env:"TRUSTED_PROXY" description:"reverse-proxy networks (CIDR or IP) trusted to set the client IP; if unset, trusted from any client (see docs)" env-delim:","`
|
||||
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
|
||||
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
|
||||
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
|
||||
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
|
||||
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
|
||||
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors''" env-delim:","`
|
||||
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors'" env-delim:","`
|
||||
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
|
||||
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
|
||||
DisableFancyTextFormatting bool `long:"disable-fancy-text-formatting" env:"DISABLE_FANCY_TEXT_FORMATTING" description:"disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc)"`
|
||||
@@ -101,7 +102,7 @@ type ServerCommand struct {
|
||||
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
|
||||
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
|
||||
|
||||
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of server-set cookie; with this enabled, frontend stores the JWT in a client-side cookie (note: increases vulnerability to XSS attacks)"`
|
||||
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"also send JWT as a header, so the frontend can store it in a client-side cookie that survives third-party cookie blocking; server-set cookies are still sent (note: increases vulnerability to XSS attacks)"`
|
||||
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
|
||||
|
||||
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
|
||||
@@ -123,10 +124,10 @@ type ServerCommand struct {
|
||||
Subject string `long:"subj" env:"SUBJ" default:"remark42 confirmation" description:"email's subject"`
|
||||
ContentType string `long:"content-type" env:"CONTENT_TYPE" default:"text/html" description:"content type"`
|
||||
Host string `long:"host" env:"HOST" description:"[deprecated, use --smtp.host] SMTP host"`
|
||||
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP password"`
|
||||
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP port"`
|
||||
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] enable TLS"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] SMTP TCP connection timeout"`
|
||||
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP port"`
|
||||
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP password"`
|
||||
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] SMTP user name"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] enable TLS"`
|
||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"[deprecated, use --smtp.timeout] SMTP TCP connection timeout"`
|
||||
MsgTemplate string `long:"template" env:"TEMPLATE" description:"[deprecated] message template file" default:"email_confirmation_login.html.tmpl"`
|
||||
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
|
||||
@@ -251,6 +252,7 @@ type TelegramGroup struct {
|
||||
type SMTPGroup struct {
|
||||
Host string `long:"host" env:"HOST" description:"SMTP host"`
|
||||
Port int `long:"port" env:"PORT" description:"SMTP port"`
|
||||
HELOHost string `long:"helo_host" env:"HELO_HOST" description:"SMTP HELO/EHLO hostname"`
|
||||
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
||||
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
|
||||
@@ -283,8 +285,8 @@ type NotifyGroup struct {
|
||||
} `group:"slack" namespace:"slack" env-namespace:"SLACK"`
|
||||
Webhook struct {
|
||||
URL string `long:"url" env:"URL" description:"webhook URL for admin notifications"`
|
||||
Template string `long:"template" env:"TEMPLATE" description:"webhook authentication template" default:"{\"text\": \"{{.Text}}\"}"`
|
||||
Headers []string `long:"headers" description:"webhook authentication headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code bellow to allow , inside ""
|
||||
Template string `long:"template" env:"TEMPLATE" description:"webhook payload template (Go text/template); falls back to {\"text\": {{.Text | escapeJSONString}}} when empty"`
|
||||
Headers []string `long:"headers" description:"webhook headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code below to allow , inside ""
|
||||
Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"webhook timeout" default:"5s"`
|
||||
} `group:"webhook" namespace:"webhook" env-namespace:"WEBHOOK"`
|
||||
}
|
||||
@@ -596,6 +598,18 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
}
|
||||
log.Printf("[INFO] root url=%s", s.RemarkURL)
|
||||
|
||||
// parse trusted proxies up front so a bad CIDR fails before any resource is allocated
|
||||
trustedProxies, err := api.ParseTrustedProxies(s.TrustedProxies)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --trusted-proxy: %w", err)
|
||||
}
|
||||
switch {
|
||||
case len(trustedProxies) == 0:
|
||||
log.Printf("[WARN] --trusted-proxy not set: forwarding headers are trusted from any client and can be spoofed to bypass rate limiting / vote dedup; set it behind a reverse proxy (see docs)")
|
||||
case api.TrustsAnyPeer(trustedProxies):
|
||||
log.Printf("[WARN] --trusted-proxy has a catch-all (0.0.0.0/0 or ::/0): forwarding headers are trusted from any client, re-opening the spoofing bypass; scope it to your proxy network")
|
||||
}
|
||||
|
||||
storeEngine, err := s.makeDataStore()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make data store engine: %w", err)
|
||||
@@ -703,6 +717,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
Migrator: migr,
|
||||
ReadOnlyAge: s.ReadOnlyAge,
|
||||
SharedSecret: s.SharedSecret,
|
||||
TrustedProxies: trustedProxies,
|
||||
Authenticator: authenticator,
|
||||
Cache: loadingCache,
|
||||
NotifyService: notifyService,
|
||||
@@ -1152,6 +1167,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
params := sender.EmailParams{
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
HELOHost: s.SMTP.HELOHost,
|
||||
SMTPUserName: s.SMTP.Username,
|
||||
SMTPPassword: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
@@ -1308,6 +1324,7 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
|
||||
smtpParams := ntf.SMTPParams{
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
HELOHost: s.SMTP.HELOHost,
|
||||
TLS: s.SMTP.TLS,
|
||||
StartTLS: s.SMTP.StartTLS,
|
||||
InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
|
||||
|
||||
+273
-87
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -25,15 +24,33 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
// budget for a server to bind and answer, generous enough for a loaded CI runner
|
||||
serverStartTimeout = 30 * time.Second
|
||||
serverStartPoll = 10 * time.Millisecond
|
||||
|
||||
// budget for a server to stop once asked. tight enough to catch a shutdown that hangs,
|
||||
// loose enough not to depend on how loaded the runner is
|
||||
serverStopTimeout = 10 * time.Second
|
||||
|
||||
// connect budget for a single probe. kept off the poll interval so a slow loopback connect
|
||||
// on a loaded runner does not look like a server that is not listening
|
||||
probeDialTimeout = time.Second
|
||||
|
||||
// the /auth/ group is limited to 2 req/s, so retries sit at its refill interval rather than
|
||||
// above it, which would only manufacture more 429s
|
||||
authRetryPoll = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
func TestServerApp(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
return o
|
||||
})
|
||||
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
@@ -68,7 +85,7 @@ func TestServerApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerApp_DevMode(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
o.AdminPasswd = "password"
|
||||
@@ -77,7 +94,7 @@ func TestServerApp_DevMode(t *testing.T) {
|
||||
})
|
||||
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 11+1, len(providers), "extra auth provider")
|
||||
@@ -97,7 +114,7 @@ func TestServerApp_DevMode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerApp_CustomOAuthProvider(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
o.Auth.Custom.Name = "oidc"
|
||||
@@ -110,7 +127,7 @@ func TestServerApp_CustomOAuthProvider(t *testing.T) {
|
||||
})
|
||||
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 11+1, len(providers), "extra auth provider")
|
||||
@@ -121,7 +138,7 @@ func TestServerApp_CustomOAuthProvider(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerApp_AnonMode(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
o.Auth.Anonymous = true
|
||||
@@ -129,7 +146,7 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
})
|
||||
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 11+1, len(providers), "extra auth provider for anon")
|
||||
@@ -148,8 +165,7 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
assert.Equal(t, "pong", string(body))
|
||||
|
||||
// try to login with good name
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -168,57 +184,43 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// try to login with non-latin name
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
nonLatin := fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port)
|
||||
resp = getRetryThrottled(t, &client, nonLatin)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// try to login with bad name
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with short name
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with name what have space in prefix
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with name what have space in suffix
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with long name
|
||||
time.Sleep(time.Second)
|
||||
ln := strings.Repeat("x", 65)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with admin name
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// try to add a comment as anonymous with admin name
|
||||
time.Sleep(time.Second)
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
@@ -250,12 +252,12 @@ func getAuthFromCookie(t *testing.T, app *serverApp, resp *http.Response) (tkn s
|
||||
|
||||
func TestServerApp_WithSSL(t *testing.T) {
|
||||
opts := ServerCommand{}
|
||||
sslPort := chooseRandomUnusedPort()
|
||||
sslPort := chooseUnusedPort(t)
|
||||
opts.SetCommon(CommonOpts{RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), SharedSecret: "123456"})
|
||||
|
||||
// prepare options
|
||||
p := flags.NewParser(&opts, flags.Default)
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=" + strconv.Itoa(port), "--store.bolt.path=/tmp/xyz", "--backup=/tmp",
|
||||
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db",
|
||||
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
|
||||
@@ -270,8 +272,9 @@ func TestServerApp_WithSSL(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel() // this context is not the one createAppFromCmd registers for cleanup
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPSServerStart(sslPort)
|
||||
waitForServerStart(t, sslPort, port) // the redirect check below uses the plain http port
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
@@ -312,7 +315,7 @@ func TestServerApp_WithRemote(t *testing.T) {
|
||||
|
||||
// prepare options
|
||||
p := flags.NewParser(&opts, flags.Default)
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
|
||||
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
|
||||
"--port=" + strconv.Itoa(port), "--avatar.fs.path=/tmp",
|
||||
@@ -326,8 +329,9 @@ func TestServerApp_WithRemote(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel() // this context is not the one createAppFromCmd registers for cleanup
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
@@ -377,6 +381,16 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
|
||||
t.Log(err)
|
||||
|
||||
// invalid trusted proxy CIDR fails fast, before any resource is created
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&opts, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--backup=/tmp", "--trusted-proxy=nonsense"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, `invalid --trusted-proxy: invalid trusted proxy "nonsense"`)
|
||||
t.Log(err)
|
||||
|
||||
// wrong store type
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
@@ -506,34 +520,117 @@ func TestServerApp_InvalidCustomOAuthProviderName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerApp_Shutdown(t *testing.T) {
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = chooseRandomUnusedPort()
|
||||
o.Port = port
|
||||
return o
|
||||
})
|
||||
time.AfterFunc(100*time.Millisecond, func() {
|
||||
cancel()
|
||||
})
|
||||
st := time.Now()
|
||||
err := app.run(ctx)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100msec")
|
||||
|
||||
// cancel once the server actually answers, so the test measures shutdown and not startup.
|
||||
// the deferred cancel also covers a failed wait, keeping app.run from racing the next test
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.run(ctx) }()
|
||||
defer cancel()
|
||||
waitForHTTPServerStart(t, port)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(serverStopTimeout):
|
||||
t.Fatal("server app did not stop after context cancel")
|
||||
}
|
||||
app.Wait()
|
||||
}
|
||||
|
||||
func TestServerApp_MainSignal(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-done
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||
// TestServerApp_ClaimsUpd covers the hook the authenticator runs on every token mint, refresh
|
||||
// included: it stamps admin, blocked and email onto the claims and blocks impersonation of a
|
||||
// restricted name. Calling the updater directly keeps it independent of when a token expires.
|
||||
func TestServerApp_ClaimsUpd(t *testing.T) {
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
return o
|
||||
})
|
||||
|
||||
// the app owns stores and services that only run closes, so it goes through the usual
|
||||
// lifecycle here rather than being built and abandoned
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(t, port)
|
||||
defer app.Wait()
|
||||
defer cancel()
|
||||
|
||||
upd := app.restSrv.Authenticator.TokenService().ClaimsUpd
|
||||
require.NotNil(t, upd, "claims updater wired into the token service")
|
||||
|
||||
claimsFor := func(id, name string) token.Claims {
|
||||
return token.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"remark"}},
|
||||
User: &token.User{ID: id, Name: name},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("plain user gets no attributes", func(t *testing.T) {
|
||||
res := upd.Update(claimsFor("provider1_dev", "developer"))
|
||||
assert.False(t, res.User.IsAdmin(), "not an admin")
|
||||
assert.False(t, res.User.BoolAttr("blocked"), "not blocked")
|
||||
assert.Empty(t, res.User.Email, "no email on file")
|
||||
})
|
||||
|
||||
t.Run("admin from the admin store", func(t *testing.T) {
|
||||
res := upd.Update(claimsFor("id1", "admin one"))
|
||||
assert.True(t, res.User.IsAdmin(), "id1 is listed as admin")
|
||||
})
|
||||
|
||||
t.Run("blocked user carries the blocked attribute", func(t *testing.T) {
|
||||
require.NoError(t, app.restSrv.DataService.SetBlock("remark", "blocked_user", true, time.Hour))
|
||||
res := upd.Update(claimsFor("blocked_user", "blocked"))
|
||||
assert.True(t, res.User.BoolAttr("blocked"), "block is reflected on refresh")
|
||||
})
|
||||
|
||||
t.Run("email is read from the store", func(t *testing.T) {
|
||||
_, err := app.restSrv.DataService.SetUserEmail("remark", "with_email", "user@example.com")
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
res := upd.Update(claimsFor("with_email", "someone"))
|
||||
assert.Equal(t, "user@example.com", res.User.Email)
|
||||
})
|
||||
|
||||
t.Run("anonymous impersonating a restricted name is blocked", func(t *testing.T) {
|
||||
res := upd.Update(claimsFor("anonymous_x", " UmpUtun "))
|
||||
assert.True(t, res.User.BoolAttr("blocked"), "restricted name matched case and space insensitively")
|
||||
})
|
||||
|
||||
t.Run("email user impersonating a restricted name is blocked", func(t *testing.T) {
|
||||
res := upd.Update(claimsFor("email_x", "bobuk"))
|
||||
assert.True(t, res.User.BoolAttr("blocked"))
|
||||
})
|
||||
|
||||
t.Run("regular user may carry a restricted name", func(t *testing.T) {
|
||||
res := upd.Update(claimsFor("provider1_someone", "umputun"))
|
||||
assert.False(t, res.User.BoolAttr("blocked"), "only anonymous and email logins are checked")
|
||||
})
|
||||
|
||||
t.Run("claims without a user pass through", func(t *testing.T) {
|
||||
res := upd.Update(token.Claims{RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"remark"}}})
|
||||
assert.Nil(t, res.User)
|
||||
})
|
||||
|
||||
t.Run("claims without exactly one audience pass through", func(t *testing.T) {
|
||||
c := claimsFor("id1", "admin one")
|
||||
c.Audience = jwt.ClaimStrings{"remark", "second"}
|
||||
res := upd.Update(c)
|
||||
assert.False(t, res.User.IsAdmin(), "attributes need a single audience to resolve the site")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerApp_MainSignal(t *testing.T) {
|
||||
sigErr := make(chan error, 1)
|
||||
|
||||
s := ServerCommand{}
|
||||
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
|
||||
p := flags.NewParser(&s, flags.Default)
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt",
|
||||
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--image.fs.path=/tmp"}
|
||||
defer os.Remove("/tmp/xyz")
|
||||
@@ -541,11 +638,52 @@ func TestServerApp_MainSignal(t *testing.T) {
|
||||
defer os.Remove("/tmp/ava-test.db")
|
||||
_, err := p.ParseArgs(args)
|
||||
require.NoError(t, err)
|
||||
st := time.Now()
|
||||
close(done)
|
||||
// the signal goes out only once the server answers: SIGTERM landing before the handler is
|
||||
// installed kills the test process, so a wait that timed out reports instead of sending it
|
||||
go func() {
|
||||
started := waitForServerPort(port, serverStartTimeout)
|
||||
// signal either way: Execute blocks until it gets one, so bailing out here would hang
|
||||
// the test until the package timeout instead of failing with the reason
|
||||
killErr := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||
if !started {
|
||||
killErr = fmt.Errorf("server on port %d didn't start", port)
|
||||
}
|
||||
sigErr <- killErr
|
||||
}()
|
||||
|
||||
err = s.Execute(args)
|
||||
assert.NoError(t, err, "execute should be without errors")
|
||||
assert.True(t, time.Since(st).Seconds() < 5, "should take under five sec", time.Since(st).Seconds())
|
||||
require.NoError(t, <-sigErr, "SIGTERM not delivered")
|
||||
}
|
||||
|
||||
func TestServerApp_RunCanceledBeforeRESTStart(t *testing.T) {
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
return o
|
||||
})
|
||||
cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- app.run(ctx) }()
|
||||
|
||||
// the budget is generous on purpose: the assertion is that run exits rather than hangs, and
|
||||
// store construction can take a while on a loaded runner
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.NoError(t, err)
|
||||
app.Wait()
|
||||
case <-time.After(serverStartTimeout):
|
||||
waitForHTTPServerStart(t, port)
|
||||
app.restSrv.Shutdown()
|
||||
select {
|
||||
case <-errCh:
|
||||
app.Wait()
|
||||
case <-time.After(serverStartTimeout):
|
||||
t.Fatal("server app did not stop after forced REST shutdown")
|
||||
}
|
||||
t.Fatal("server app should exit when context is canceled before REST server starts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||
@@ -709,24 +847,25 @@ func Test_ACMEEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerAuthHooks(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
return o
|
||||
})
|
||||
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
// make a token for user dev
|
||||
// make a token for user dev. nothing here checks expiry, so the lifetime only has to
|
||||
// outlast the whole test
|
||||
tkService := app.restSrv.Authenticator.TokenService()
|
||||
tkService.TokenDuration = time.Second
|
||||
tkService.TokenDuration = time.Hour
|
||||
|
||||
claims := token.Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark"},
|
||||
Issuer: "remark",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
@@ -829,8 +968,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
|
||||
"blocked user can't post, \n"+tk+"\n"+string(body))
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "blocked user can't post, \n"+tk+"\n"+string(body))
|
||||
|
||||
cancel()
|
||||
app.Wait()
|
||||
@@ -930,40 +1068,79 @@ func Test_getAllowedRedirectHosts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||
// collision between concurrently running package test binaries very unlikely
|
||||
func chooseUnusedPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", ":0")
|
||||
require.NoError(t, err, "no free port available")
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
return port
|
||||
}
|
||||
|
||||
func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 3 seconds for server to start before returning it
|
||||
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
||||
// port if it never does
|
||||
func waitForHTTPServerStart(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
client := http.Client{Timeout: time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return true
|
||||
}, serverStartTimeout, serverStartPoll, "http server on port %d didn't start", port)
|
||||
}
|
||||
|
||||
// waitForServerStart blocks until something accepts on every listed port, failing the test
|
||||
// naming the port that never came up
|
||||
func waitForServerStart(t *testing.T, ports ...int) {
|
||||
t.Helper()
|
||||
for _, port := range ports {
|
||||
require.True(t, waitForServerPort(port, serverStartTimeout), "server on port %d didn't start", port)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForHTTPSServerStart(port int) {
|
||||
// wait for up to 3 seconds for HTTPS server to start
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
break
|
||||
// getRetryThrottled issues a GET and retries while the auth routes answer 429, since the /auth/
|
||||
// group is limited to 2 req/s and this test logs in more often than that. a transport error is
|
||||
// retried a couple of times and then reported as itself, so a dead server is not read as throttling
|
||||
func getRetryThrottled(t *testing.T, client *http.Client, url string) *http.Response {
|
||||
t.Helper()
|
||||
const transportRetries = 2
|
||||
errCount := 0
|
||||
for deadline := time.Now().Add(serverStartTimeout); time.Now().Before(deadline); time.Sleep(authRetryPoll) {
|
||||
r, err := client.Get(url)
|
||||
if err != nil {
|
||||
errCount++
|
||||
require.LessOrEqual(t, errCount, transportRetries, "request to %s failed: %v", url, err)
|
||||
continue
|
||||
}
|
||||
if r.StatusCode == http.StatusTooManyRequests {
|
||||
_ = r.Body.Close()
|
||||
continue
|
||||
}
|
||||
return r
|
||||
}
|
||||
t.Fatalf("request to %s kept being rate limited", url)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForServerPort blocks until something accepts on port, reporting whether it came up.
|
||||
// unlike the require-based helpers it is safe to call off the test goroutine.
|
||||
func waitForServerPort(port int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), probeDialTimeout)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
time.Sleep(serverStartPoll)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
||||
@@ -1026,6 +1203,9 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
|
||||
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// a require in a readiness wait exits the test goroutine, so without this an app started in
|
||||
// a goroutine would never be stopped and goleak would report it instead of the failure
|
||||
t.Cleanup(cancel)
|
||||
app, err := cmd.newServerApp(ctx)
|
||||
require.NoError(t, err)
|
||||
return app, ctx, cancel
|
||||
@@ -1035,8 +1215,14 @@ func TestMain(m *testing.M) {
|
||||
// ignore is added only for GitHub Actions, can't reproduce locally
|
||||
goleak.VerifyTestMain(
|
||||
m,
|
||||
// the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
|
||||
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
|
||||
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
||||
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
||||
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
||||
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
||||
)
|
||||
}
|
||||
|
||||
+34
-26
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -25,7 +24,7 @@ func Test_Main(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
||||
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"}
|
||||
|
||||
@@ -48,7 +47,7 @@ func Test_Main(t *testing.T) {
|
||||
<-finished
|
||||
}()
|
||||
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
@@ -63,9 +62,9 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
var webhookSent int32
|
||||
var webhookSent atomic.Int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
atomic.StoreInt32(&webhookSent, 1)
|
||||
webhookSent.Store(1)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
b, e := io.ReadAll(r.Body)
|
||||
@@ -76,7 +75,7 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
||||
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg",
|
||||
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
|
||||
@@ -107,7 +106,7 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
<-finished
|
||||
}()
|
||||
|
||||
waitForHTTPServerStart(port)
|
||||
waitForHTTPServerStart(t, port)
|
||||
|
||||
resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", port), "",
|
||||
strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`))
|
||||
@@ -117,8 +116,8 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
|
||||
// wait for webhook to be sent before shutting down
|
||||
assert.Eventually(t, func() bool {
|
||||
return atomic.LoadInt32(&webhookSent) == int32(1)
|
||||
}, time.Second, 100*time.Millisecond, "webhook was not sent")
|
||||
return webhookSent.Load() == int32(1)
|
||||
}, 30*time.Second, 10*time.Millisecond, "webhook was not sent")
|
||||
}
|
||||
|
||||
func TestGetDump(t *testing.T) {
|
||||
@@ -129,37 +128,46 @@ func TestGetDump(t *testing.T) {
|
||||
t.Logf("\n dump: %s", dump)
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||
// collision between concurrently running package test binaries very unlikely
|
||||
func chooseUnusedPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", ":0")
|
||||
require.NoError(t, err, "no free port available")
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
return port
|
||||
}
|
||||
|
||||
func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 10 seconds for server to start before returning it
|
||||
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
||||
// port if it never does
|
||||
func waitForHTTPServerStart(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
client := http.Client{Timeout: time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for range 100 {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return
|
||||
require.Eventually(t, func() bool {
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return true
|
||||
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// both ignores are for leaks which are detected locally
|
||||
goleak.VerifyTestMain(
|
||||
m,
|
||||
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
||||
// the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
|
||||
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
|
||||
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
||||
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
||||
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
||||
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
||||
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -50,9 +51,7 @@ func TestBackup_MakeBackup(t *testing.T) {
|
||||
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
|
||||
assert.Equal(t, expFile, fname)
|
||||
|
||||
fi, err := os.Lstat(expFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(52), fi.Size())
|
||||
assert.Equal(t, exportedPayload, gzContent(t, expFile))
|
||||
}
|
||||
|
||||
func TestBackup_Do(t *testing.T) {
|
||||
@@ -71,15 +70,31 @@ func TestBackup_Do(t *testing.T) {
|
||||
bk.Do(ctx)
|
||||
|
||||
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
|
||||
fi, err := os.Lstat(expFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(52), fi.Size())
|
||||
assert.Equal(t, exportedPayload, gzContent(t, expFile))
|
||||
})
|
||||
}
|
||||
|
||||
const exportedPayload = "some export blah blah 1234567890"
|
||||
|
||||
// the compressed size is not assertable: it moves with the compress/flate version
|
||||
func gzContent(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
fh, err := os.Open(name) //nolint:gosec // path is built by the test
|
||||
require.NoError(t, err)
|
||||
defer func() { assert.NoError(t, fh.Close()) }()
|
||||
|
||||
gz, err := gzip.NewReader(fh)
|
||||
require.NoError(t, err)
|
||||
defer func() { assert.NoError(t, gz.Close()) }()
|
||||
|
||||
b, err := io.ReadAll(gz)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
type mockExporter struct{}
|
||||
|
||||
func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) {
|
||||
_, err := w.Write([]byte("some export blah blah 1234567890"))
|
||||
_, err := w.Write([]byte(exportedPayload))
|
||||
return 1000, err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
@@ -46,8 +47,8 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
|
||||
|
||||
log.Printf("[DEBUG] exporting %d topics", len(topics))
|
||||
commentsCount := 0
|
||||
for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction
|
||||
topic := topics[i]
|
||||
for _, topic := range slices.Backward(topics) { // topics from List sorted in opposite direction
|
||||
|
||||
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser)
|
||||
if e != nil {
|
||||
return commentsCount, e
|
||||
|
||||
+35
-10
@@ -3,15 +3,16 @@ package notify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/url"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/go-pkgz/repeater/v2"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/templates"
|
||||
)
|
||||
@@ -42,12 +43,12 @@ type Email struct {
|
||||
type msgTmplData struct {
|
||||
UserName string
|
||||
UserPicture string
|
||||
CommentText string
|
||||
CommentText template.HTML
|
||||
CommentLink string
|
||||
CommentDate time.Time
|
||||
ParentUserName string
|
||||
ParentUserPicture string
|
||||
ParentCommentText string
|
||||
ParentCommentText template.HTML
|
||||
ParentCommentLink string
|
||||
ParentCommentDate time.Time
|
||||
PostTitle string
|
||||
@@ -56,6 +57,30 @@ type msgTmplData struct {
|
||||
ForAdmin bool
|
||||
}
|
||||
|
||||
// emailCommentPolicy sanitizes comment HTML for inclusion in notification emails.
|
||||
// It is intentionally stricter than the store-level UGC policy used for web rendering:
|
||||
// links (<a>) and images (<img>) are dropped so a comment can't smuggle phishing links
|
||||
// or remote tracking pixels into an email sent from the legitimate remark42 address,
|
||||
// while basic inline and block text formatting is preserved.
|
||||
var emailCommentPolicy = func() *bluemonday.Policy {
|
||||
p := bluemonday.NewPolicy()
|
||||
p.AllowElements(
|
||||
"p", "br", "hr", "div", "span",
|
||||
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sub", "sup", "mark", "small",
|
||||
"blockquote", "q", "cite",
|
||||
"code", "pre", "kbd", "samp", "var",
|
||||
"ul", "ol", "li", "dl", "dt", "dd",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
)
|
||||
return p
|
||||
}()
|
||||
|
||||
// emailSafeHTML strips links and images from pre-rendered comment HTML and returns
|
||||
// it as template.HTML so html/template renders the remaining safe formatting as-is.
|
||||
func emailSafeHTML(commentHTML string) template.HTML {
|
||||
return template.HTML(emailCommentPolicy.Sanitize(commentHTML)) //nolint:gosec // sanitized above: <a>/<img> dropped, only formatting tags survive
|
||||
}
|
||||
|
||||
// verifyTmplData store data for verification message template execution
|
||||
type verifyTmplData struct {
|
||||
User string
|
||||
@@ -135,23 +160,23 @@ func (e *Email) Send(ctx context.Context, req Request) error {
|
||||
default:
|
||||
}
|
||||
|
||||
result := new(multierror.Error)
|
||||
var errs []error
|
||||
|
||||
for _, email := range req.Emails {
|
||||
err := e.buildAndSendMessage(ctx, req, email, false)
|
||||
if err != nil {
|
||||
result = multierror.Append(fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
||||
errs = append(errs, fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
||||
}
|
||||
}
|
||||
|
||||
for _, email := range e.AdminEmails {
|
||||
err := e.buildAndSendMessage(ctx, req, email, true)
|
||||
if err != nil {
|
||||
result = multierror.Append(fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
||||
errs = append(errs, fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
||||
}
|
||||
}
|
||||
|
||||
return result.ErrorOrNil()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
||||
@@ -257,7 +282,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
|
||||
tmplData := msgTmplData{
|
||||
UserName: req.Comment.User.Name,
|
||||
UserPicture: req.Comment.User.Picture,
|
||||
CommentText: req.Comment.Text,
|
||||
CommentText: emailSafeHTML(req.Comment.Text),
|
||||
CommentLink: commentURLPrefix + req.Comment.ID,
|
||||
CommentDate: req.Comment.Timestamp,
|
||||
PostTitle: req.Comment.PostTitle,
|
||||
@@ -269,7 +294,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
|
||||
if req.Comment.ParentID != "" {
|
||||
tmplData.ParentUserName = req.parent.User.Name
|
||||
tmplData.ParentUserPicture = req.parent.User.Picture
|
||||
tmplData.ParentCommentText = req.parent.Text
|
||||
tmplData.ParentCommentText = emailSafeHTML(req.parent.Text)
|
||||
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
|
||||
tmplData.ParentCommentDate = req.parent.Timestamp
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ package notify
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -110,10 +110,10 @@ func TestEmailSendErrors(t *testing.T) {
|
||||
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
|
||||
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\": "+
|
||||
"problem sending user email notification to \"bad@example.org\": "+
|
||||
"error executing template to build comment reply message: "+
|
||||
"template: test:1:2: executing \"test\" at <.Test>: "+
|
||||
"can't evaluate field Test in type notify.msgTmplData\n\n")
|
||||
"can't evaluate field Test in type notify.msgTmplData")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -121,8 +121,14 @@ func TestEmailSendErrors(t *testing.T) {
|
||||
"sending email messages about comment \"999\" aborted due to canceled context")
|
||||
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}),
|
||||
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\":"+
|
||||
" error creating token for unsubscribe link: token generation error\n\n")
|
||||
"problem sending user email notification to \"bad@example.org\":"+
|
||||
" error creating token for unsubscribe link: token generation error")
|
||||
|
||||
// errors for all failed recipients are reported, not just the last one
|
||||
assert.EqualError(t, e.Send(context.Background(),
|
||||
Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad1@example.org", "bad2@example.org"}}),
|
||||
"problem sending user email notification to \"bad1@example.org\": error creating token for unsubscribe link: token generation error\n"+
|
||||
"problem sending user email notification to \"bad2@example.org\": error creating token for unsubscribe link: token generation error")
|
||||
}
|
||||
|
||||
func TestEmailSend_ExitConditions(t *testing.T) {
|
||||
@@ -164,7 +170,7 @@ User: test_user
|
||||
01.01.0001 at 00:00
|
||||
Comment:
|
||||
test@example.org for parent_user
|
||||
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
|
||||
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
|
||||
`, msg.body)
|
||||
assert.Equal(t, "https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token", msg.unsubscribeLink)
|
||||
assert.Equal(t, `New reply to your comment for "test_title"`, msg.subject)
|
||||
@@ -190,6 +196,50 @@ admin@example.org
|
||||
assert.Empty(t, msg.unsubscribeLink)
|
||||
}
|
||||
|
||||
func TestEmail_CommentTextSanitizedForEmail(t *testing.T) {
|
||||
// comment HTML reaching the email path is sanitized by the store-level UGC policy,
|
||||
// which permits <a> and <img>. The email must drop both so a comment can't inject
|
||||
// phishing links or remote tracking pixels into a notification (GHSA-74pc-3r2m-ppx3).
|
||||
email, err := NewEmail(EmailParams{
|
||||
From: "from@example.org",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, ntf.SMTPParams{})
|
||||
require.NoError(t, err)
|
||||
email.TokenGenFn = TokenGenFn
|
||||
|
||||
malicious := `hello <a href="https://phishing.example/verify">click to verify</a>` +
|
||||
` <img src="https://attacker.example/track.png" width="1" height="1"> <b>kept</b>`
|
||||
req := Request{
|
||||
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title", Text: malicious},
|
||||
Emails: []string{"test@example.org"},
|
||||
}
|
||||
msg, err := email.buildMessageFromRequest(req, req.Emails[0], false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, msg.body, "phishing.example", "phishing link must be stripped")
|
||||
assert.NotContains(t, msg.body, "attacker.example", "tracking pixel must be stripped")
|
||||
assert.NotContains(t, msg.body, "<img", "no image tags in email body")
|
||||
assert.NotContains(t, msg.body, "<a ", "no anchor tags in email body")
|
||||
assert.Contains(t, msg.body, "click to verify", "anchor text is preserved, only the link is dropped")
|
||||
assert.Contains(t, msg.body, "<b>kept</b>", "basic formatting is preserved")
|
||||
}
|
||||
|
||||
// emailSafeHTML drops links/images while keeping inline/block formatting and escaping nothing extra.
|
||||
func TestEmailSafeHTML(t *testing.T) {
|
||||
tbl := []struct{ name, in, want string }{
|
||||
{"strips anchor keeps text", `<a href="http://evil">x</a>`, "x"},
|
||||
{"strips image entirely", `a<img src="http://evil/t.png">b`, "ab"},
|
||||
{"keeps bold/italic/code", `<b>b</b><i>i</i><code>c</code>`, `<b>b</b><i>i</i><code>c</code>`},
|
||||
{"keeps blockquote and lists", `<blockquote>q</blockquote><ul><li>x</li></ul>`, `<blockquote>q</blockquote><ul><li>x</li></ul>`},
|
||||
{"drops onclick handlers", `<span onclick="alert(1)">s</span>`, `<span>s</span>`},
|
||||
}
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, string(emailSafeHTML(tt.in)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail_SendVerification(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{
|
||||
From: "from@example.org",
|
||||
|
||||
@@ -19,7 +19,7 @@ type Service struct {
|
||||
queue chan Request
|
||||
verificationQueue chan VerificationRequest
|
||||
|
||||
closed uint32 // non-zero means closed. uses uint instead of bool for atomic
|
||||
closed atomic.Uint32 // non-zero means closed. uses uint instead of bool for atomic
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
|
||||
|
||||
// Submit Request to internal channel if not busy, drop if can't send
|
||||
func (s *Service) Submit(req Request) {
|
||||
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
|
||||
if len(s.destinations) == 0 || s.closed.Load() != 0 {
|
||||
return
|
||||
}
|
||||
if s.dataService != nil && req.Comment.ParentID != "" {
|
||||
@@ -130,7 +130,7 @@ func (s *Service) getNotificationTargets(
|
||||
|
||||
// SubmitVerification to internal channel if not busy, drop if can't send
|
||||
func (s *Service) SubmitVerification(req VerificationRequest) {
|
||||
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
|
||||
if len(s.destinations) == 0 || s.closed.Load() != 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
@@ -155,7 +155,7 @@ func (s *Service) Close() {
|
||||
s.cancel()
|
||||
<-s.ctx.Done()
|
||||
}
|
||||
atomic.StoreUint32(&s.closed, 1)
|
||||
s.closed.Store(1)
|
||||
}
|
||||
|
||||
func (s *Service) do() {
|
||||
|
||||
@@ -15,10 +15,14 @@ type MockDest struct {
|
||||
id int
|
||||
closed bool
|
||||
lock sync.Mutex
|
||||
block chan struct{} // if non-nil, Send/SendVerification wait on it before recording, letting tests pin the consumer
|
||||
}
|
||||
|
||||
// Send mock
|
||||
func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
if m.block != nil {
|
||||
<-m.block
|
||||
}
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -33,6 +37,9 @@ func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
|
||||
// SendVerification mock
|
||||
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
|
||||
if m.block != nil {
|
||||
<-m.block
|
||||
}
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
||||
@@ -2,7 +2,6 @@ package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
|
||||
@@ -49,26 +48,38 @@ func TestService_WithDestinations(t *testing.T) {
|
||||
|
||||
func TestService_WithDrops(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
// gated destinations pin the consumer on the first item so the size-1 queue
|
||||
// fills deterministically and the overflow is dropped regardless of scheduling
|
||||
gate := make(chan struct{})
|
||||
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}}) // consumed, consumer blocks in Send on the gate
|
||||
synctest.Wait()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}}) // fills the size-1 queue
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}}) // queue full, dropped
|
||||
synctest.Wait()
|
||||
|
||||
close(gate) // release the consumer: it finishes 100 then processes 101
|
||||
synctest.Wait()
|
||||
s.Close()
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
|
||||
|
||||
assert.LessOrEqual(t, len(d1.Get()), 2, "at least one comment from three dropped from d1, got: %v", d1.Get())
|
||||
assert.LessOrEqual(t, len(d2.Get()), 2, "at least one comment from three dropped from d2, got: %v", d2.Get())
|
||||
require.Len(t, d1.Get(), 2, "one comment of three dropped from d1, got: %v", d1.Get())
|
||||
require.Len(t, d2.Get(), 2, "one comment of three dropped from d2, got: %v", d2.Get())
|
||||
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
||||
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_SubmitVerificationWithDrops(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
// gated destinations pin the consumer on the first item so the size-1 queue
|
||||
// fills deterministically and the overflow is dropped regardless of scheduling
|
||||
gate := make(chan struct{})
|
||||
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
@@ -77,22 +88,27 @@ func TestService_SubmitVerificationWithDrops(t *testing.T) {
|
||||
User: "testUser",
|
||||
Email: "test@example.org",
|
||||
Token: "testToken",
|
||||
})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
}) // consumed, consumer blocks in SendVerification on the gate
|
||||
synctest.Wait()
|
||||
s.SubmitVerification(VerificationRequest{User: "second"}) // fills the size-1 queue
|
||||
s.SubmitVerification(VerificationRequest{User: "dropped"}) // queue full, dropped
|
||||
synctest.Wait()
|
||||
|
||||
close(gate) // release the consumer: it finishes testUser then processes second
|
||||
synctest.Wait()
|
||||
s.Close()
|
||||
|
||||
s.SubmitVerification(VerificationRequest{}) // safe to send after close
|
||||
|
||||
assert.LessOrEqual(t, len(d2.GetVerify()), 2, "one request from three dropped from d2, got: %v", d2.GetVerify())
|
||||
require.Len(t, d2.GetVerify(), 2, "one request of three dropped from d2, got: %v", d2.GetVerify())
|
||||
|
||||
verifyDest := d1.GetVerify()
|
||||
require.LessOrEqual(t, len(verifyDest), 2, "one request from three dropped from d1, got: %v", verifyDest)
|
||||
require.Len(t, verifyDest, 2, "one request of three dropped from d1, got: %v", verifyDest)
|
||||
assert.Equal(t, "remark", verifyDest[0].SiteID)
|
||||
assert.Equal(t, "testUser", verifyDest[0].User)
|
||||
assert.Equal(t, "test@example.org", verifyDest[0].Email)
|
||||
assert.Equal(t, "testToken", verifyDest[0].Token)
|
||||
assert.Equal(t, "second", verifyDest[1].User)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,7 +297,7 @@ func TestService_Nop(t *testing.T) {
|
||||
s := NopService
|
||||
s.Submit(Request{Comment: store.Comment{}})
|
||||
s.Close()
|
||||
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
|
||||
assert.Equal(t, uint32(1), s.closed.Load())
|
||||
}
|
||||
|
||||
type mockStore struct {
|
||||
|
||||
@@ -2,12 +2,12 @@ package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
|
||||
const commentTextLengthLimit = 100
|
||||
@@ -47,14 +47,14 @@ func NewTelegram(params TelegramParams) (*Telegram, error) {
|
||||
// Send to telegram recipients
|
||||
func (t *Telegram) Send(ctx context.Context, req Request) error {
|
||||
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
|
||||
result := new(multierror.Error)
|
||||
var errs []error
|
||||
|
||||
msg := t.buildMessage(req)
|
||||
|
||||
if t.AdminChannelID != "" {
|
||||
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg)
|
||||
if err != nil {
|
||||
result = multierror.Append(result,
|
||||
errs = append(errs,
|
||||
fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w",
|
||||
req.Comment.ID, t.AdminChannelID, err,
|
||||
),
|
||||
@@ -66,7 +66,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
|
||||
for _, user := range req.Telegrams {
|
||||
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg)
|
||||
if err != nil {
|
||||
result = multierror.Append(result,
|
||||
errs = append(errs,
|
||||
fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w",
|
||||
req.Comment.ID, user, err,
|
||||
),
|
||||
@@ -74,7 +74,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// buildMessage generates message for generic notification about new comment
|
||||
|
||||
@@ -30,7 +30,6 @@ func TestTelegram_Send(t *testing.T) {
|
||||
|
||||
err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "2 errors occurred")
|
||||
assert.Contains(t, err.Error(), "problem sending user telegram notification about comment ID 999 to \"test_user_channel\"")
|
||||
assert.Contains(t, err.Error(), "problem sending admin telegram notification about comment ID 999 to remark_test")
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
@@ -43,7 +45,7 @@ type adminStore interface {
|
||||
|
||||
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
|
||||
func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
id := r.PathValue("id")
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
log.Printf("[INFO] delete comment %s", id)
|
||||
|
||||
@@ -58,7 +60,7 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
|
||||
func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userid")
|
||||
userID := r.PathValue("userid")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
|
||||
|
||||
@@ -72,7 +74,7 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GET /user/{userid}?site=side-id - get user info for requested userid
|
||||
func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userid")
|
||||
userID := r.PathValue("userid")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
|
||||
|
||||
@@ -123,10 +125,15 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil {
|
||||
avatarStore := a.authenticator.AvatarProxy().Store
|
||||
if err = avatarStore.Remove(path.Base(claims.User.Picture)); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar", rest.ErrInternal)
|
||||
return
|
||||
if avatarID := avatarIDFromPicture(claims.User.Picture); avatarID != "" {
|
||||
// an already-removed avatar is fine (a repeated request stays idempotent), but a genuine
|
||||
// store failure is surfaced now that avatar.ErrNotFound lets us tell the two apart
|
||||
if err = a.authenticator.AvatarProxy().Store.Remove(avatarID); err != nil && !errors.Is(err, avatar.ErrNotFound) {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user's avatar", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Printf("[WARN] unexpected avatar picture %q for user %s on site %s, skipping removal", claims.User.Picture, claims.User.ID, audience)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +141,20 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
R.RenderJSON(w, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
|
||||
}
|
||||
|
||||
// avatarIDFromPicture returns the avatar-store object id for a user picture, or "" if the picture
|
||||
// does not resolve to a well-formed id (the store names its objects "<hash>.image"). Guarding on the
|
||||
// id shape keeps a malformed picture, e.g. a path sentinel, from making a filesystem-backed store
|
||||
// target an unexpected path.
|
||||
func avatarIDFromPicture(picture string) string {
|
||||
if id := path.Base(picture); strings.HasSuffix(id, ".image") {
|
||||
return id
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user
|
||||
func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userid")
|
||||
userID := r.PathValue("userid")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
blockStatus := r.URL.Query().Get("block") == "1"
|
||||
|
||||
@@ -202,7 +220,7 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title
|
||||
func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
id := r.PathValue("id")
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
|
||||
c, err := a.dataService.SetTitle(locator, id)
|
||||
@@ -216,9 +234,9 @@ func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
||||
}
|
||||
|
||||
// PUT /verify?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
||||
// PUT /verify/{userid}?site=siteID&verified=1 - set or reset verified status for the user
|
||||
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userid")
|
||||
userID := r.PathValue("userid")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
verifyStatus := r.URL.Query().Get("verified") == "1"
|
||||
|
||||
@@ -233,7 +251,7 @@ func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
// PUT /pin/{id}?site=siteID&url=post-url&pin=1
|
||||
// mark/unmark comment as a special
|
||||
func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
commentID := chi.URLParam(r, "id")
|
||||
commentID := r.PathValue("id")
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
pinStatus := r.URL.Query().Get("pin") == "1"
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -75,14 +75,22 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
assert.Equal(t, "", cr.Text)
|
||||
assert.True(t, cr.Deleted)
|
||||
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
// check last comments updated
|
||||
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = []store.Comment{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(comments), "should have 1 comments")
|
||||
// the last-comments list refreshes asynchronously after the delete. the polling closure runs
|
||||
// off the test goroutine, so it asserts on the CollectT it is handed rather than on t, which
|
||||
// also puts the real transport or decode error in the failure message
|
||||
pollClient := http.Client{Timeout: waitTimeout}
|
||||
defer pollClient.CloseIdleConnections()
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
lastResp, gErr := pollClient.Get(ts.URL + "/api/v1/last/2?site=remark42")
|
||||
if !assert.NoError(c, gErr) {
|
||||
return
|
||||
}
|
||||
defer lastResp.Body.Close()
|
||||
assert.Equal(c, http.StatusOK, lastResp.StatusCode)
|
||||
last := []store.Comment{}
|
||||
assert.NoError(c, json.NewDecoder(lastResp.Body).Decode(&last))
|
||||
assert.Len(c, last, 1, "should have 1 comments")
|
||||
}, waitTimeout, httpPoll)
|
||||
|
||||
// check count updated
|
||||
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
|
||||
@@ -139,7 +147,7 @@ func TestAdmin_Title(t *testing.T) {
|
||||
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -174,7 +182,7 @@ func TestAdmin_DeleteUser(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -275,7 +283,7 @@ func TestAdmin_Block(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
@@ -333,10 +341,12 @@ func TestAdmin_Block(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, j["block"])
|
||||
|
||||
// block with ttl
|
||||
// block with ttl, checked in place rather than through another admin request, which would
|
||||
// push this test over the 10 req/s limit on that route
|
||||
makeTwoComments()
|
||||
code, _ = block(1, "50ms")
|
||||
code, _ = block(1, "500ms")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.True(t, srv.adminRest.dataService.IsBlocked("remark42", "user1"), "user1 blocked with ttl")
|
||||
|
||||
// get as regular user
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
@@ -350,7 +360,13 @@ func TestAdmin_Block(t *testing.T) {
|
||||
|
||||
srv.pubRest.cache = cache.NewScache[[]byte](cache.NewNopCache[[]byte]()) // TODO: with lru cache it won't be refreshed and invalidated for long
|
||||
// time
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// the ttl above is wide enough that the checks in between cannot outlast it, so reaching
|
||||
// here still inside the block, and the wait below observes it lapse
|
||||
require.Eventually(t, func() bool {
|
||||
return !srv.adminRest.dataService.IsBlocked("remark42", "user1")
|
||||
}, waitTimeout, pollInterval, "block with ttl did not expire")
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
@@ -383,23 +399,23 @@ func TestAdmin_BlockedList(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
res, err := sendReq(t, req, adminUmputunToken)
|
||||
res, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
// block user2
|
||||
// block user2 for long enough that the "two users blocked" check below cannot race the ttl
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=1h", ts.URL, "user2", 1), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
res, err = sendReq(t, req, adminUmputunToken)
|
||||
res, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
res, err = sendReq(t, req, adminUmputunToken)
|
||||
res, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
users := []store.BlockedUser{}
|
||||
@@ -412,18 +428,33 @@ func TestAdmin_BlockedList(t *testing.T) {
|
||||
assert.Equal(t, "user2", users[1].ID)
|
||||
assert.Equal(t, "user2 name", users[1].Name)
|
||||
t.Logf("%+v", users)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||
// re-block user2 with a short ttl and wait for it to lapse, so the lapse is observed
|
||||
// independently of the check above
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
res, err = sendReq(t, req, adminUmputunToken)
|
||||
res, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
users = []store.BlockedUser{}
|
||||
err = json.NewDecoder(res.Body).Decode(&users)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Equal(t, 1, len(users), "one user left blocked")
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
// the closure runs off the test goroutine and asserts on the CollectT it is handed, never on t
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
blockedReq, reqErr := http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||
if !assert.NoError(c, reqErr) {
|
||||
return
|
||||
}
|
||||
blockedResp, sendErr := sendReq(blockedReq, adminUmputunToken)
|
||||
if !assert.NoError(c, sendErr) {
|
||||
return
|
||||
}
|
||||
defer blockedResp.Body.Close()
|
||||
assert.Equal(c, http.StatusOK, blockedResp.StatusCode)
|
||||
blocked := []store.BlockedUser{}
|
||||
assert.NoError(c, json.NewDecoder(blockedResp.Body).Decode(&blocked))
|
||||
assert.Len(c, blocked, 1, "one user left blocked")
|
||||
}, waitTimeout, httpPoll)
|
||||
}
|
||||
|
||||
func TestAdmin_ReadOnly(t *testing.T) {
|
||||
@@ -448,11 +479,11 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err := sendReq(t, req, "") // non-admin user
|
||||
resp, err := sendReq(req, "") // non-admin user
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -467,7 +498,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
assert.NoError(t, err, "can't marshal comment %+v", c)
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", bytes.NewBuffer(b))
|
||||
require.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
@@ -476,7 +507,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -491,7 +522,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
assert.NoError(t, err, "can't marshal comment %+v", c)
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site="+c.Locator.SiteID, bytes.NewBuffer(b))
|
||||
require.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -506,7 +537,7 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -553,7 +584,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -565,7 +596,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
@@ -594,7 +625,7 @@ func TestAdmin_Verify(t *testing.T) {
|
||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -613,7 +644,7 @@ func TestAdmin_Verify(t *testing.T) {
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -664,7 +695,7 @@ func TestAdmin_ExportFile(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -717,7 +748,7 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "user1",
|
||||
Picture: "pic.image",
|
||||
Picture: "https://demo.remark42.com/api/v1/avatar/pic.image", // production-shaped URL: removal must path.Base it to the avatar id
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
@@ -747,6 +778,124 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
||||
email, err = srv.DataService.GetUserEmail("remark42", "user1")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, email, "user1 email was deleted")
|
||||
|
||||
assert.NoFileExists(t, os.TempDir()+"/ava-remark42/42/pic.image", "user's avatar should be removed on deleteme")
|
||||
}
|
||||
|
||||
// a delete_me request whose token carries a picture must still succeed when the avatar is
|
||||
// already gone from the store: the user data is deleted and a missing avatar is tolerated
|
||||
func TestAdmin_DeleteMeRequestMissingAvatar(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
||||
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user3 name", ID: "user3"}}
|
||||
_, err := srv.DataService.Create(c1)
|
||||
require.NoError(t, err)
|
||||
|
||||
claims := token.Claims{
|
||||
SessionOnly: true,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark42"},
|
||||
ID: "2345678",
|
||||
Issuer: "remark42",
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "user3",
|
||||
Picture: "missing.image", // no avatar file exists for this picture in the store
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "a missing avatar must not fail the deletion")
|
||||
|
||||
_, err = srv.DataService.User("remark42", "user3", 0, 0, store.User{})
|
||||
assert.EqualError(t, err, "no comments for user user3 in store", "user3 comments should be deleted")
|
||||
}
|
||||
|
||||
// a genuine (non not-found) avatar-store failure must now surface, not be silently swallowed:
|
||||
// avatar.ErrNotFound lets deleteMeRequestCtrl tell an already-gone avatar from a real error
|
||||
func TestAdmin_DeleteMeRequestAvatarRemoveError(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
||||
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user5 name", ID: "user5"}}
|
||||
_, err := srv.DataService.Create(c1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// put a non-empty directory where the avatar file is expected, so Store.Remove fails with a real
|
||||
// error (directory not empty), not os.ErrNotExist - "pic" hashes to partition 42
|
||||
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42/pic.image", 0o700))
|
||||
require.NoError(t, os.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image/child", []byte("x"), 0o600))
|
||||
|
||||
claims := token.Claims{
|
||||
SessionOnly: true,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark42"},
|
||||
ID: "4567890",
|
||||
Issuer: "remark42",
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "user5",
|
||||
Picture: "https://demo.remark42.com/api/v1/avatar/pic.image",
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "a real avatar-store failure must surface, not be swallowed")
|
||||
}
|
||||
|
||||
func TestAvatarIDFromPicture(t *testing.T) {
|
||||
tbl := []struct {
|
||||
name string
|
||||
picture string
|
||||
want string
|
||||
}{
|
||||
{"local avatar url", "https://demo.remark42.com/api/v1/avatar/cb42ff493ade696d88a3a590f136ae9e34de7c1b.image", "cb42ff493ade696d88a3a590f136ae9e34de7c1b.image"},
|
||||
{"bare avatar id", "pic.image", "pic.image"},
|
||||
{"parent sentinel", "https://demo.remark42.com/api/v1/avatar/..", ""},
|
||||
{"trailing slash", "https://demo.remark42.com/api/v1/avatar/", ""},
|
||||
{"root", "/", ""},
|
||||
{"dotdot", "..", ""},
|
||||
{"empty", "", ""},
|
||||
{"provider url without image suffix", "https://example.com/pic.png", ""},
|
||||
}
|
||||
for _, tc := range tbl {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, avatarIDFromPicture(tc.picture))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
@@ -802,7 +951,8 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try bad user
|
||||
// unknown user: deletion is idempotent, so a valid (signed) delete_me token for a user with
|
||||
// no stored data is a no-op success rather than an error
|
||||
badClaimsUser := claims
|
||||
badClaimsUser.User.ID = "no-such-id"
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaimsUser)
|
||||
@@ -813,7 +963,7 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
resp, err = client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.Status)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.Status)
|
||||
badClaimsUser.User.ID = "provider1_user1"
|
||||
|
||||
// try without deleteme flag
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
// Package api middleware: request-scoped HTTP middlewares used by the REST router.
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/didip/tollbooth/v8"
|
||||
"github.com/didip/tollbooth/v8/limiter"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// ipForwardingHeaders are the request headers R.RealIP derives the client IP from.
|
||||
var ipForwardingHeaders = []string{"X-Real-IP", "X-Forwarded-For", "CF-Connecting-IP"}
|
||||
|
||||
// realIPMiddleware derives the client IP from forwarding headers (X-Real-IP / X-Forwarded-For /
|
||||
// CF-Connecting-IP) via R.RealIP, but honors those headers only for requests whose direct peer
|
||||
// is one of the trusted proxies. For any other peer it drops those headers and pins RemoteAddr to
|
||||
// the real socket IP, so an untrusted client can't spoof the IP that per-IP controls (rate limiting,
|
||||
// vote dedup, comment IP, anonymous id) and the request log key on.
|
||||
//
|
||||
// With no trusted proxies configured it falls back to trusting the headers from any client (the
|
||||
// historical behavior). That is spoofable by design, so operators running behind a reverse proxy
|
||||
// should set --trusted-proxy to the proxy's network — see the "trusted proxy" docs.
|
||||
func realIPMiddleware(trustedProxies []*net.IPNet) func(http.Handler) http.Handler {
|
||||
if len(trustedProxies) == 0 {
|
||||
return R.RealIP
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
fromTrusted := R.RealIP(next) // rewrites RemoteAddr from the forwarding headers
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
peer := directPeerIP(r.RemoteAddr)
|
||||
if peer != nil && cidrsContain(trustedProxies, peer) {
|
||||
fromTrusted.ServeHTTP(w, r) // trusted proxy: honor the forwarding headers
|
||||
return
|
||||
}
|
||||
// untrusted peer: drop the forwarding headers and pin RemoteAddr to the real socket IP,
|
||||
// so nothing downstream can be fooled by a spoofed header (R.RealIP normalizes
|
||||
// RemoteAddr to a bare IP for trusted peers; do the same here for consistency)
|
||||
for _, h := range ipForwardingHeaders {
|
||||
r.Header.Del(h)
|
||||
}
|
||||
if peer != nil {
|
||||
r.RemoteAddr = peer.String()
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// directPeerIP extracts the IP from a "host:port" (or bare host) RemoteAddr, or nil if unparseable.
|
||||
func directPeerIP(remoteAddr string) net.IP {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
host = remoteAddr // may already be a bare IP with no port
|
||||
}
|
||||
return net.ParseIP(host)
|
||||
}
|
||||
|
||||
// TrustsAnyPeer reports whether the trusted-proxy list contains a catch-all (0.0.0.0/0 or ::/0),
|
||||
// which trusts forwarding headers from every client and re-opens the IP-spoofing bypass.
|
||||
func TrustsAnyPeer(cidrs []*net.IPNet) bool {
|
||||
for _, c := range cidrs {
|
||||
if ones, _ := c.Mask.Size(); ones == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cidrsContain reports whether ip falls within any of the CIDRs.
|
||||
func cidrsContain(cidrs []*net.IPNet, ip net.IP) bool {
|
||||
for _, c := range cidrs {
|
||||
if c.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseTrustedProxies parses a list of trusted-proxy entries into CIDRs. Each entry may be a CIDR
|
||||
// (e.g. 172.16.0.0/12) or a bare IP (treated as a single host). Blank entries are skipped; a
|
||||
// malformed entry is a hard error so a typo can't silently disable proxy trust.
|
||||
func ParseTrustedProxies(entries []string) ([]*net.IPNet, error) {
|
||||
var out []*net.IPNet
|
||||
for _, e := range entries {
|
||||
e = strings.TrimSpace(e)
|
||||
if e == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(e, "/") { // bare IP -> single-host CIDR
|
||||
ip := net.ParseIP(e)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid trusted proxy %q", e)
|
||||
}
|
||||
// build the network from the normalized IP so a v4-mapped IPv6 (e.g. ::ffff:10.0.0.1)
|
||||
// yields the intended /32 host, not a huge ::/32 range
|
||||
bits := 128
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip, bits = v4, 32
|
||||
}
|
||||
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
||||
continue
|
||||
}
|
||||
_, network, err := net.ParseCIDR(e)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", e, err)
|
||||
}
|
||||
out = append(out, network)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// corsMiddleware builds the CORS middleware for the public API. With AllowedOrigins
|
||||
// "*" and credentials enabled, rest.CORS reflects the request Origin into
|
||||
// Access-Control-Allow-Origin (rather than a literal "*"), which browsers require
|
||||
// for credentialed cross-origin requests.
|
||||
//
|
||||
// That combination is refused by default upstream, so it has to be asked for by name with
|
||||
// CorsUnsafeAnyOriginWithCredentials. The wildcard stays because the comment widget is embedded on
|
||||
// arbitrary third-party sites, which makes the set of origins unknowable. The consequence it carries
|
||||
// is that any site a signed-in user visits can read authenticated responses, so state-changing
|
||||
// requests have to keep being protected by something other than the origin, X-XSRF-Token today.
|
||||
func corsMiddleware() func(http.Handler) http.Handler {
|
||||
return R.CORS(
|
||||
R.CorsAllowedOrigins("*"),
|
||||
R.CorsAllowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"),
|
||||
R.CorsAllowedHeaders("Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"),
|
||||
R.CorsExposedHeaders("Authorization"),
|
||||
R.CorsAllowCredentials(true),
|
||||
R.CorsUnsafeAnyOriginWithCredentials(true),
|
||||
R.CorsMaxAge(300),
|
||||
)
|
||||
}
|
||||
|
||||
// rejectHead rejects HEAD requests with 405, advertising the given allowed methods in
|
||||
// the Allow header. net/http.ServeMux routes HEAD to a "GET ..." handler, but per RFC
|
||||
// 9110 GET/HEAD are safe methods; this guard is applied to the few GET routes whose
|
||||
// handlers mutate state so they cannot be triggered by a (nominally side-effect-free)
|
||||
// HEAD, preserving the pre-routegroup behavior. allow lists every method the resource
|
||||
// supports (e.g. "GET" or "GET, POST") so the 405 Allow header is accurate.
|
||||
func rejectHead(allow string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Allow", allow)
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// rejectAnonUser is a middleware rejecting anonymous users
|
||||
func rejectAnonUser(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := rest.GetUserInfo(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(user.ID, "anonymous_") {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
|
||||
func matchSiteID(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := rest.GetUserInfo(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// skip for basic auth user
|
||||
if user.Name == "admin" && user.ID == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
// require an explicit site so the user.SiteID check below cannot be bypassed
|
||||
// by simply omitting the query parameter
|
||||
if siteID == "" || user.SiteID != siteID {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
// cacheControl is a middleware setting cache expiration. Using url+version as etag
|
||||
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
|
||||
etag := func(r *http.Request, version string) string {
|
||||
s := version + ":" + r.URL.String()
|
||||
return store.EncodeID(s)
|
||||
}
|
||||
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
e := `"` + etag(r, version) + `"`
|
||||
w.Header().Set("Etag", e)
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
|
||||
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
if strings.Contains(match, e) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
|
||||
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
|
||||
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
|
||||
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
|
||||
// images — none of those should ever execute scripts when rendered, so they get the
|
||||
// strictest policy available as defense-in-depth against future trust-boundary bugs.
|
||||
//
|
||||
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
|
||||
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
|
||||
// inline; filename="image" (framing the response as a file rather than a renderable
|
||||
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
|
||||
// braces: if a future route refactor bypasses this middleware, the image handlers still
|
||||
// emit the policy.
|
||||
func apiCSPMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// securityHeadersMiddleware sets security-related headers:
|
||||
// - Content-Security-Policy: controls which resources the browser is allowed to load
|
||||
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
|
||||
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
|
||||
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
|
||||
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
|
||||
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
|
||||
// and nothing at all on HTTPS→HTTP downgrades
|
||||
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
imgSrc := "*"
|
||||
if imageProxyEnabled {
|
||||
imgSrc = "'self'"
|
||||
}
|
||||
frameAncestors := "*"
|
||||
if len(allowedAncestors) > 0 {
|
||||
frameAncestors = strings.Join(allowedAncestors, " ")
|
||||
}
|
||||
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
|
||||
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
|
||||
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// subscribersOnly is a middleware rejecting non-paid_sub users
|
||||
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
if enable {
|
||||
user, err := rest.GetUserInfo(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !user.PaidSub {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// validEmailAuth is a middleware for auth endpoints for email method.
|
||||
// it rejects login request if user, site or email are suspicious
|
||||
func validEmailAuth() func(http.Handler) http.Handler {
|
||||
|
||||
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
|
||||
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
|
||||
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.URL.Path != "/auth/email/login" {
|
||||
// not email login, skip the check
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if u := r.URL.Query().Get("user"); u != "" {
|
||||
if !reUser.MatchString(u) {
|
||||
log.Printf("[WARN] suspicious user rejected: %s", u)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if a := r.URL.Query().Get("address"); a != "" {
|
||||
if _, err := mail.ParseAddress(a); err != nil {
|
||||
log.Printf("[WARN] suspicious address rejected: %s", a)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if s := r.URL.Query().Get("site"); s != "" {
|
||||
if !reSite.MatchString(s) {
|
||||
log.Printf("[WARN] suspicious site rejected: %s", s)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
|
||||
// tollbooth v8 requires explicit IP lookup method to be set.
|
||||
// keys on RemoteAddr, which realIPMiddleware sets to the client IP (from the forwarding
|
||||
// headers for trusted proxies, otherwise the real socket IP).
|
||||
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
|
||||
lmt := tollbooth.NewLimiter(maxReq, nil)
|
||||
lmt.SetIPLookup(limiter.IPLookup{
|
||||
Name: "RemoteAddr",
|
||||
IndexFromRight: 0,
|
||||
})
|
||||
return tollbooth.HTTPMiddleware(lmt)
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/go-pkgz/routegroup"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// routes() wraps bounded routes with the enforcing rest.Timeout and deliberately leaves the
|
||||
// streaming/long-polling routes (GET /export, /userdata, /wait) without it. This checks that
|
||||
// contract holds against the vendored middleware: a slow handler under R.Timeout is aborted with
|
||||
// 504 at the deadline, while a route left without it runs to completion.
|
||||
func TestRouteTimeout(t *testing.T) {
|
||||
slow := func(d time.Duration) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done(): // return promptly once the enforcing timeout cancels the context
|
||||
case <-time.After(d):
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
router.With(R.Timeout(20*time.Millisecond)).HandleFunc("GET /bounded", slow(time.Second))
|
||||
router.HandleFunc("GET /streaming", slow(30*time.Millisecond)) // no timeout, like /export and /wait
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/bounded")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "route under R.Timeout is aborted at the deadline")
|
||||
|
||||
resp, err = http.Get(ts.URL + "/streaming")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "route without R.Timeout runs to completion")
|
||||
}
|
||||
|
||||
// TestRateLimiter covers the middleware guarding every route group: a burst past the per-second
|
||||
// allowance is refused with 429, and a client under the allowance is not. The limiter keys on
|
||||
// RemoteAddr, so the two cases use different ones rather than waiting for a bucket to refill.
|
||||
func TestRateLimiter(t *testing.T) {
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
router.With(rateLimiter(1)).HandleFunc("GET /limited", func(http.ResponseWriter, *http.Request) {})
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
call := func(remoteAddr string) int {
|
||||
req := httptest.NewRequest("GET", "http://example.com/limited", http.NoBody)
|
||||
req.RemoteAddr = remoteAddr
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// one request a second is allowed, so the first of a burst passes and the rest are refused
|
||||
assert.Equal(t, http.StatusOK, call("1.2.3.4:1000"), "first request within the allowance")
|
||||
refused := 0
|
||||
for range 5 {
|
||||
if call("1.2.3.4:1000") == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 5, refused, "burst past the allowance is refused")
|
||||
|
||||
// a different client has its own bucket and is unaffected
|
||||
assert.Equal(t, http.StatusOK, call("5.6.7.8:1000"), "limit is per client, not global")
|
||||
}
|
||||
|
||||
func TestRealIPMiddleware(t *testing.T) {
|
||||
// call runs mw with the given peer and (optional) X-Real-IP header and returns what the
|
||||
// downstream handler observes; state is per-call, so subtests don't share closure locals.
|
||||
call := func(mw func(http.Handler) http.Handler, remoteAddr, xRealIP string) (addr, hdr string) {
|
||||
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
addr, hdr = r.RemoteAddr, r.Header.Get("X-Real-IP")
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
req.RemoteAddr = remoteAddr
|
||||
if xRealIP != "" {
|
||||
req.Header.Set("X-Real-IP", xRealIP)
|
||||
}
|
||||
mw(next).ServeHTTP(httptest.NewRecorder(), req)
|
||||
return addr, hdr
|
||||
}
|
||||
|
||||
trusted, err := ParseTrustedProxies([]string{"172.16.0.0/12", "2001:db8::/32"})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("no trusted proxies trusts the header from anyone (legacy)", func(t *testing.T) {
|
||||
addr, _ := call(realIPMiddleware(nil), "203.0.113.9:1234", "8.8.8.8")
|
||||
assert.Equal(t, "8.8.8.8", addr)
|
||||
})
|
||||
t.Run("trusted v4 peer: forwarding header sets the client IP", func(t *testing.T) {
|
||||
addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "8.8.8.8")
|
||||
assert.Equal(t, "8.8.8.8", addr)
|
||||
})
|
||||
t.Run("trusted v6 peer: forwarding header honored", func(t *testing.T) {
|
||||
addr, _ := call(realIPMiddleware(trusted), "[2001:db8::5]:5555", "8.8.8.8")
|
||||
assert.Equal(t, "8.8.8.8", addr)
|
||||
})
|
||||
t.Run("trusted peer without a forwarding header falls back to the socket IP", func(t *testing.T) {
|
||||
addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "")
|
||||
assert.Equal(t, "172.18.0.5", addr, "no header to honor, so the bare socket IP is used")
|
||||
})
|
||||
t.Run("untrusted peer: header stripped, RemoteAddr pinned to bare socket IP", func(t *testing.T) {
|
||||
addr, hdr := call(realIPMiddleware(trusted), "203.0.113.9:1234", "8.8.8.8")
|
||||
assert.Equal(t, "203.0.113.9", addr, "real socket IP with the port stripped")
|
||||
assert.Empty(t, hdr, "spoofed forwarding header removed so nothing downstream can read it")
|
||||
})
|
||||
t.Run("unparseable RemoteAddr is treated as untrusted, header stripped", func(t *testing.T) {
|
||||
addr, hdr := call(realIPMiddleware(trusted), "garbage", "8.8.8.8")
|
||||
assert.Equal(t, "garbage", addr, "unparseable peer left as-is, not overwritten")
|
||||
assert.Empty(t, hdr, "forwarding header still stripped for a non-trusted peer")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseTrustedProxies(t *testing.T) {
|
||||
t.Run("cidr, bare v4, bare v6, blanks", func(t *testing.T) {
|
||||
got, err := ParseTrustedProxies([]string{"172.16.0.0/12", " 10.0.0.1 ", "", "2001:db8::/32"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
assert.True(t, got[0].Contains(net.ParseIP("172.18.0.5")))
|
||||
assert.True(t, got[1].Contains(net.ParseIP("10.0.0.1")))
|
||||
assert.False(t, got[1].Contains(net.ParseIP("10.0.0.2")), "a bare IP is a single host")
|
||||
assert.True(t, got[2].Contains(net.ParseIP("2001:db8::1")))
|
||||
})
|
||||
t.Run("v4-mapped IPv6 bare entry resolves to the v4 host", func(t *testing.T) {
|
||||
got, err := ParseTrustedProxies([]string{"::ffff:10.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.True(t, got[0].Contains(net.ParseIP("10.0.0.1")), "the intended /32 host")
|
||||
assert.False(t, got[0].Contains(net.ParseIP("10.0.0.2")), "not a wider range")
|
||||
})
|
||||
t.Run("malformed entry is a hard error", func(t *testing.T) {
|
||||
_, err := ParseTrustedProxies([]string{"172.16.0.0/12", "nonsense"})
|
||||
require.Error(t, err)
|
||||
_, err = ParseTrustedProxies([]string{"10.0.0.0/999"})
|
||||
require.Error(t, err)
|
||||
})
|
||||
t.Run("all blank yields nil", func(t *testing.T) {
|
||||
got, err := ParseTrustedProxies([]string{"", " "})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
})
|
||||
}
|
||||
|
||||
func TestTrustsAnyPeer(t *testing.T) {
|
||||
catchAll := func(entries ...string) bool {
|
||||
cidrs, err := ParseTrustedProxies(entries)
|
||||
require.NoError(t, err)
|
||||
return TrustsAnyPeer(cidrs)
|
||||
}
|
||||
assert.True(t, catchAll("10.0.0.0/8", "0.0.0.0/0"), "v4 catch-all")
|
||||
assert.True(t, catchAll("::/0"), "v6 catch-all")
|
||||
assert.False(t, catchAll("172.16.0.0/12", "10.0.0.5"), "scoped ranges are not catch-all")
|
||||
assert.False(t, catchAll(), "empty is not catch-all")
|
||||
}
|
||||
|
||||
func TestRest_rejectAnonUser(t *testing.T) {
|
||||
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprintln(w, "Hello")
|
||||
}))))
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
|
||||
|
||||
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
|
||||
|
||||
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
|
||||
}
|
||||
|
||||
func TestRest_cacheControl(t *testing.T) {
|
||||
tbl := []struct {
|
||||
url string
|
||||
version string
|
||||
exp time.Duration
|
||||
etag string
|
||||
maxAge int
|
||||
}{
|
||||
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
|
||||
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
|
||||
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
|
||||
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.url, http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
t.Logf("%+v", resp.Header)
|
||||
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
|
||||
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none'
|
||||
// override regardless of what the global CSP allows. The widget HTML pages
|
||||
// (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap),
|
||||
// so the test asserts the two policies diverge across origins.
|
||||
func TestRest_apiCSP(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
client := http.Client{}
|
||||
|
||||
// JSON API endpoint — must carry the strict policy
|
||||
resp, err := client.Get(ts.URL + "/api/v1/config")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp, "default-src 'none'",
|
||||
"API responses must override the global CSP with default-src 'none'; got %q", csp)
|
||||
assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp)
|
||||
assert.NotContains(t, csp, "'unsafe-inline'",
|
||||
"API CSP must not allow inline scripts/styles; got %q", csp)
|
||||
|
||||
// RSS/XML endpoint — same strict policy, and the XML response itself must still be served
|
||||
respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42")
|
||||
require.NoError(t, err)
|
||||
defer respRSS.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP")
|
||||
cspRSS := respRSS.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP")
|
||||
assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox")
|
||||
|
||||
// widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap)
|
||||
resp2, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
csp2 := resp2.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp2, "'unsafe-inline'",
|
||||
"widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2)
|
||||
}
|
||||
|
||||
// check CSP, img-src should be 'self' with proxy enabled and * without it
|
||||
func TestRest_securityHeaders(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
|
||||
// with proxy disabled
|
||||
client := http.Client{}
|
||||
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
|
||||
// until the test ends, so the body has to be released before the server is torn down here
|
||||
require.NoError(t, resp.Body.Close())
|
||||
client.CloseIdleConnections()
|
||||
teardown()
|
||||
|
||||
// check CSP with proxy enabled
|
||||
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||
srv.ExternalImageProxy = true
|
||||
})
|
||||
defer teardown()
|
||||
resp, err = client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
}
|
||||
|
||||
func TestRest_subscribersOnly(t *testing.T) {
|
||||
paidSubUser := &token.User{}
|
||||
paidSubUser.SetPaidSub(true)
|
||||
|
||||
tbl := []struct {
|
||||
subsOnly bool
|
||||
user token.User
|
||||
setUser bool
|
||||
status int
|
||||
}{
|
||||
{true, token.User{}, false, http.StatusUnauthorized},
|
||||
{true, token.User{}, true, http.StatusForbidden},
|
||||
{false, token.User{}, false, http.StatusOK},
|
||||
{false, token.User{}, true, http.StatusOK},
|
||||
{true, *paidSubUser, true, http.StatusOK},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
|
||||
if tt.setUser {
|
||||
req = token.SetUserInfo(req, tt.user)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, tt.status, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validEmailAuth(t *testing.T) {
|
||||
tbl := []struct {
|
||||
req string
|
||||
status int
|
||||
}{
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden},
|
||||
{"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden},
|
||||
{"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden},
|
||||
{"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, tt.status, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRest_matchSiteID reproduces the multi-tenant isolation gap in the matchSiteID
|
||||
// middleware. Before the fix, the check `if siteID != "" && user.SiteID != siteID`
|
||||
// silently allowed any authenticated request that omitted the ?site= query param.
|
||||
// On admin and user-mutation routes this meant the cross-site check was bypassable
|
||||
// just by dropping the parameter. The fix requires ?site= to be present and to match
|
||||
// the user's bound site.
|
||||
func TestRest_matchSiteID(t *testing.T) {
|
||||
wrapped := matchSiteID(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
userSite string
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{name: "matching site allowed", userSite: "site-a", query: "?site=site-a", want: http.StatusOK},
|
||||
{name: "mismatched site forbidden", userSite: "site-a", query: "?site=site-b", want: http.StatusForbidden},
|
||||
{name: "missing site param rejected", userSite: "site-a", query: "", want: http.StatusForbidden},
|
||||
{name: "empty site param rejected", userSite: "site-a", query: "?site=", want: http.StatusForbidden},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = rest.SetUserInfo(r, store.User{ID: "u", Name: "u", SiteID: c.userSite})
|
||||
wrapped.ServeHTTP(w, r)
|
||||
})
|
||||
ts := httptest.NewServer(h)
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + c.query)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, c.want, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorsMiddleware(t *testing.T) {
|
||||
h := corsMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
t.Run("credentialed cross-origin reflects the request origin", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
// AllowedOrigins "*" with credentials must reflect the origin, never a literal "*"
|
||||
assert.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
assert.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
|
||||
assert.Equal(t, "Authorization", rec.Header().Get("Access-Control-Expose-Headers"))
|
||||
})
|
||||
|
||||
t.Run("preflight advertises configured methods, headers and max-age", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodOptions, "/", http.NoBody)
|
||||
req.Header.Set("Origin", "https://example.com")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "POST")
|
||||
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "X-JWT")
|
||||
assert.Equal(t, "300", rec.Header().Get("Access-Control-Max-Age"))
|
||||
// preflight responses must vary on origin and the request method/headers so caches
|
||||
// don't reuse one preflight across different requests
|
||||
vary := rec.Header().Values("Vary")
|
||||
assert.Contains(t, vary, "Origin")
|
||||
assert.Contains(t, vary, "Access-Control-Request-Method")
|
||||
assert.Contains(t, vary, "Access-Control-Request-Headers")
|
||||
})
|
||||
|
||||
t.Run("same-origin request (no Origin) gets no CORS headers", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", http.NoBody))
|
||||
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
})
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
|
||||
"github.com/umputun/remark42/backend/app/migrator"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
|
||||
// Migrator rest with import and export controllers
|
||||
@@ -74,21 +77,40 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 256*1024*1024) // hard cap on upload to prevent memory exhaustion
|
||||
if err := r.ParseMultipartForm(20 * 1024 * 1024); err != nil { // 20M max memory, if bigger will make a file
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get import file from the request", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
tmpfile := ""
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
if part.FormName() != "file" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
tmpfile, err := m.saveTemp(file)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save request to temp file", rest.ErrInternal)
|
||||
tmpfile, err = m.saveTemp(part)
|
||||
if closeErr := part.Close(); err == nil && closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save request to temp file", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if tmpfile == "" {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, fmt.Errorf("file field missing"),
|
||||
"can't get import file from the request", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,7 +154,8 @@ func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
if _, err := m.NativeExporter.Export(gzWriter, siteID); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
||||
code, errCode := exportErrStatus(err)
|
||||
rest.SendErrorJSON(w, r, code, err, "export failed", errCode)
|
||||
return
|
||||
}
|
||||
if err := gzWriter.Close(); err != nil {
|
||||
@@ -152,10 +175,23 @@ func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// stream mode - write directly to response
|
||||
if _, err := m.NativeExporter.Export(w, siteID); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
||||
code, errCode := exportErrStatus(err)
|
||||
rest.SendErrorJSON(w, r, code, err, "export failed", errCode)
|
||||
}
|
||||
}
|
||||
|
||||
// exportErrStatus maps an export failure to an HTTP status and error code: an unknown
|
||||
// site is a client error (400), anything else is treated as internal (500).
|
||||
// The bolt store returns the engine.ErrSiteNotFound sentinel; the rpc store loses typed
|
||||
// errors over jrpc, so the "not found" message is matched as a fallback (export only ever
|
||||
// hits a site-level lookup, so a "not found" here can only mean the site).
|
||||
func exportErrStatus(err error) (status, errCode int) {
|
||||
if errors.Is(err, engine.ErrSiteNotFound) || strings.Contains(err.Error(), "not found") {
|
||||
return http.StatusBadRequest, rest.ErrSiteNotFound
|
||||
}
|
||||
return http.StatusInternalServerError, rest.ErrInternal
|
||||
}
|
||||
|
||||
// POST /remap?site=site-id
|
||||
// remap urls in comments based on given rules (oldUrl newUrl)
|
||||
func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestMigrator_Import(t *testing.T) {
|
||||
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
|
||||
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
||||
require.NoError(t, err)
|
||||
@@ -125,7 +125,7 @@ func TestMigrator_ImportFromWP(t *testing.T) {
|
||||
|
||||
r := strings.NewReader(strings.ReplaceAll(xmlTestWP, "'", "`"))
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=wordpress", r)
|
||||
assert.NoError(t, err)
|
||||
@@ -170,7 +170,7 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
|
||||
"email":"somegreatmail@gmail.com","name":"User5276","link":"https://example.com/profile/257","photo":"https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
|
||||
"provider":"sso:example.com","joinDate":"2021-03-19T19:27:25.954285Z","isModerator":false}]}`)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
|
||||
assert.NoError(t, err)
|
||||
@@ -211,7 +211,7 @@ func TestMigrator_ImportFromCommentoJSON(t *testing.T) {
|
||||
r, err := os.Open("testdata/commento.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
|
||||
assert.NoError(t, err)
|
||||
@@ -258,7 +258,7 @@ func TestMigrator_ImportRejected(t *testing.T) {
|
||||
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
|
||||
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native&secret=XYZ", r)
|
||||
assert.NoError(t, err)
|
||||
@@ -280,10 +280,14 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
||||
for i := range 50 {
|
||||
recs = append(recs, fmt.Sprintf(tmpl, i))
|
||||
}
|
||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
// each request needs its own reader. client.Do returns once the response headers are in, which
|
||||
// for an accepted import is before the transport's writeLoop has finished copying the body, so
|
||||
// handing the same strings.Reader to the second NewRequest races that copy: NewRequest reads
|
||||
// Len() to set ContentLength while WriteTo is still advancing it
|
||||
body := `{"version":1}` + strings.Join(recs, "\n")
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.NoError(t, err)
|
||||
@@ -294,7 +298,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
||||
|
||||
client = &http.Client{Timeout: 5 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
assert.NoError(t, err)
|
||||
@@ -380,7 +384,7 @@ func TestMigrator_Export(t *testing.T) {
|
||||
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||
|
||||
// import comments first
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
||||
require.NoError(t, err)
|
||||
@@ -391,15 +395,31 @@ func TestMigrator_Export(t *testing.T) {
|
||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
// export wrong site, should result in error
|
||||
// export unknown site is a client error, not internal
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=test", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
errBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
assert.Contains(t, string(errBody), `"code":6`) // rest.ErrSiteNotFound, not ErrInternal
|
||||
assert.Contains(t, string(errBody), `not found`) // error detail names the missing site
|
||||
|
||||
// unknown site in stream mode is also a client error
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=stream&site=test", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
errBody, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Contains(t, string(errBody), `"code":6`)
|
||||
|
||||
// check file mode
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
|
||||
@@ -557,7 +577,7 @@ func TestMigrator_RemapReject(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
// without admin credentials
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
rules := strings.NewReader(`https://remark42.com/* https://www.remark42.com/*`)
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/remap?site=remark42", rules)
|
||||
|
||||
+189
-352
@@ -3,28 +3,22 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/didip/tollbooth/v8"
|
||||
"github.com/didip/tollbooth/v8/limiter"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/go-pkgz/rest/logger"
|
||||
"github.com/go-pkgz/routegroup"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
@@ -32,6 +26,7 @@ import (
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/webassets"
|
||||
)
|
||||
|
||||
// Rest is a rest access server
|
||||
@@ -50,10 +45,11 @@ type Rest struct {
|
||||
|
||||
AnonVote bool
|
||||
WebRoot string
|
||||
WebFS embed.FS
|
||||
WebFS fs.FS
|
||||
RemarkURL string
|
||||
ReadOnlyAge int
|
||||
SharedSecret string
|
||||
TrustedProxies []*net.IPNet // reverse-proxy networks whose forwarding headers (X-Real-IP, X-Forwarded-For, ...) are trusted
|
||||
ScoreThresholds struct {
|
||||
Low int
|
||||
Critical int
|
||||
@@ -71,10 +67,11 @@ type Rest struct {
|
||||
DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
|
||||
ExternalImageProxy bool
|
||||
|
||||
SSLConfig SSLConfig
|
||||
httpsServer *http.Server
|
||||
httpServer *http.Server
|
||||
lock sync.Mutex
|
||||
SSLConfig SSLConfig
|
||||
httpsServer *http.Server
|
||||
httpServer *http.Server
|
||||
shutdownRequested bool
|
||||
lock sync.Mutex
|
||||
|
||||
pubRest public
|
||||
privRest private
|
||||
@@ -117,6 +114,11 @@ func (s *Rest) Run(address string, port int) {
|
||||
s.lock.Lock()
|
||||
s.httpServer = s.makeHTTPServer(address, port, s.routes())
|
||||
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
||||
if s.shutdownRequested {
|
||||
s.lock.Unlock()
|
||||
log.Print("[WARN] rest server start canceled")
|
||||
return
|
||||
}
|
||||
s.lock.Unlock()
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
@@ -130,6 +132,11 @@ func (s *Rest) Run(address string, port int) {
|
||||
|
||||
s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter())
|
||||
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
||||
if s.shutdownRequested {
|
||||
s.lock.Unlock()
|
||||
log.Print("[WARN] rest server start canceled")
|
||||
return
|
||||
}
|
||||
s.lock.Unlock()
|
||||
|
||||
go func() {
|
||||
@@ -150,6 +157,11 @@ func (s *Rest) Run(address string, port int) {
|
||||
|
||||
s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m))
|
||||
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
||||
if s.shutdownRequested {
|
||||
s.lock.Unlock()
|
||||
log.Print("[WARN] rest server start canceled")
|
||||
return
|
||||
}
|
||||
|
||||
s.lock.Unlock()
|
||||
|
||||
@@ -171,6 +183,7 @@ func (s *Rest) Shutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
s.lock.Lock()
|
||||
s.shutdownRequested = true
|
||||
if s.httpServer != nil {
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
log.Printf("[DEBUG] http shutdown error, %s", err)
|
||||
@@ -198,13 +211,13 @@ func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *ht
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Rest) routes() chi.Router {
|
||||
func (s *Rest) routes() http.Handler {
|
||||
if s.openRouteLimiter == 0 {
|
||||
// set the default open route limiter. Just a safety measure as it should be set by Run method anyway
|
||||
s.openRouteLimiter = openRouteLimiter
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default()))
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
router.Use(R.Throttle(1000), realIPMiddleware(s.TrustedProxies), R.Recoverer(log.Default()))
|
||||
router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors))
|
||||
if !s.DisableSignature {
|
||||
router.Use(R.AppInfo("remark42", "umputun", s.Version))
|
||||
@@ -216,15 +229,7 @@ func (s *Rest) routes() chi.Router {
|
||||
if s.ProxyCORS {
|
||||
log.Printf("[WARN] internal CORS disabled")
|
||||
} else {
|
||||
corsMiddleware := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"},
|
||||
ExposedHeaders: []string{"Authorization"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
})
|
||||
router.Use(corsMiddleware.Handler)
|
||||
router.Use(corsMiddleware())
|
||||
}
|
||||
|
||||
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
|
||||
@@ -232,143 +237,164 @@ func (s *Rest) routes() chi.Router {
|
||||
|
||||
authHandler, avatarHandler := s.Authenticator.Handlers()
|
||||
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Timeout(5 * time.Second))
|
||||
r.Use(logInfoWithBody, rateLimiter(2), middleware.NoCache)
|
||||
router.Route(func(r *routegroup.Bundle) {
|
||||
r.Use(R.Timeout(5 * time.Second))
|
||||
r.Use(logInfoWithBody, rateLimiter(2), R.NoCache)
|
||||
r.Use(validEmailAuth()) // reject suspicious email logins
|
||||
r.Mount("/auth", authHandler)
|
||||
r.Handle("/auth/", authHandler)
|
||||
})
|
||||
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Timeout(5 * time.Second))
|
||||
router.Route(func(r *routegroup.Bundle) {
|
||||
r.Use(R.Timeout(5 * time.Second))
|
||||
r.Use(rateLimiter(100))
|
||||
r.Mount("/avatar", avatarHandler)
|
||||
r.Handle("/avatar/", avatarHandler)
|
||||
})
|
||||
|
||||
authMiddleware := s.Authenticator.Middleware()
|
||||
|
||||
// api routes
|
||||
router.Route("/api/v1", func(rapi chi.Router) {
|
||||
rapi.Use(apiCSPMiddleware)
|
||||
rapi.Group(func(rava chi.Router) {
|
||||
rava.Use(middleware.Timeout(5 * time.Second))
|
||||
rava.Use(rateLimiter(100))
|
||||
rava.Mount("/avatar", avatarHandler)
|
||||
rapi := router.Mount("/api/v1")
|
||||
rapi.Use(apiCSPMiddleware)
|
||||
|
||||
rapi.Group().Route(func(rava *routegroup.Bundle) {
|
||||
rava.Use(R.Timeout(5 * time.Second))
|
||||
rava.Use(rateLimiter(100))
|
||||
rava.Handle("/avatar/", avatarHandler)
|
||||
})
|
||||
|
||||
// open routes
|
||||
rapi.Group().Route(func(ropen *routegroup.Bundle) {
|
||||
ropen.Use(R.Timeout(30 * time.Second))
|
||||
ropen.Use(rateLimiter(s.openRouteLimiter))
|
||||
ropen.Use(authMiddleware.Trace, R.NoCache, logInfoWithBody)
|
||||
ropen.HandleFunc("GET /config", s.configCtrl)
|
||||
ropen.HandleFunc("GET /find", s.pubRest.findCommentsCtrl)
|
||||
ropen.HandleFunc("GET /id/{id}", s.pubRest.commentByIDCtrl)
|
||||
ropen.HandleFunc("GET /comments", s.pubRest.findUserCommentsCtrl)
|
||||
ropen.HandleFunc("GET /last/{limit}", s.pubRest.lastCommentsCtrl)
|
||||
ropen.HandleFunc("GET /count", s.pubRest.countCtrl)
|
||||
ropen.HandleFunc("POST /counts", s.pubRest.countMultiCtrl)
|
||||
ropen.HandleFunc("GET /list", s.pubRest.listCtrl)
|
||||
ropen.HandleFunc("GET /info", s.pubRest.infoCtrl)
|
||||
|
||||
ropen.Mount("/rss").Route(func(rrss *routegroup.Bundle) {
|
||||
rrss.HandleFunc("GET /post", s.rssRest.postCommentsCtrl)
|
||||
rrss.HandleFunc("GET /site", s.rssRest.siteCommentsCtrl)
|
||||
rrss.HandleFunc("GET /reply", s.rssRest.repliesCtrl)
|
||||
})
|
||||
})
|
||||
|
||||
// open routes, cached. /img lives here (not in the NoCache group above) because
|
||||
// R.NoCache strips If-None-Match from incoming requests, which would
|
||||
// defeat the proxy handler's 304 short-circuit. The handler sets a 30-day
|
||||
// max-age on validated success responses (with a versioned etag for cache
|
||||
// invalidation on revalidation); error responses get Cache-Control: no-store
|
||||
// so transient failures aren't pinned in the cache.
|
||||
rapi.Group().Route(func(ropen *routegroup.Bundle) {
|
||||
ropen.Use(R.Timeout(30 * time.Second))
|
||||
ropen.Use(rateLimiter(10))
|
||||
ropen.Use(authMiddleware.Trace, logInfoWithBody)
|
||||
ropen.HandleFunc("GET /img", s.ImageProxy.Handler)
|
||||
ropen.HandleFunc("GET /picture/{user}/{id}", s.pubRest.loadPictureCtrl)
|
||||
ropen.HandleFunc("GET /qr/telegram", s.pubRest.telegramQrCtrl)
|
||||
})
|
||||
|
||||
// protected routes, require auth
|
||||
rapi.Group().Route(func(rauth *routegroup.Bundle) {
|
||||
rauth.Use(rateLimiter(10))
|
||||
rauth.Use(authMiddleware.Auth, matchSiteID, R.NoCache, logInfoWithBody)
|
||||
|
||||
// GET /userdata streams a gzipped export of the user's data straight to the client, so it
|
||||
// deliberately runs without R.Timeout: that middleware buffers the whole response in memory
|
||||
// before sending and aborts at the deadline, which would hold a full export in RAM and truncate it.
|
||||
rauth.HandleFunc("GET /userdata", s.privRest.userAllDataCtrl)
|
||||
|
||||
rauth.Group().Route(func(r *routegroup.Bundle) {
|
||||
r.Use(R.Timeout(30 * time.Second))
|
||||
r.HandleFunc("GET /user", s.privRest.userInfoCtrl)
|
||||
})
|
||||
})
|
||||
|
||||
// admin routes, require auth and admin users only
|
||||
rapi.Mount("/admin").Route(func(radmin *routegroup.Bundle) {
|
||||
radmin.Use(rateLimiter(10))
|
||||
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
|
||||
radmin.Use(R.NoCache, logInfoWithBody)
|
||||
|
||||
// bounded admin operations return small responses and get the enforcing request timeout
|
||||
radmin.Group().Route(func(r *routegroup.Bundle) {
|
||||
r.Use(R.Timeout(30 * time.Second))
|
||||
r.HandleFunc("DELETE /comment/{id}", s.adminRest.deleteCommentCtrl)
|
||||
r.HandleFunc("PUT /user/{userid}", s.adminRest.setBlockCtrl)
|
||||
r.HandleFunc("DELETE /user/{userid}", s.adminRest.deleteUserCtrl)
|
||||
r.HandleFunc("GET /user/{userid}", s.adminRest.getUserInfoCtrl)
|
||||
r.With(rejectHead("GET")).HandleFunc("GET /deleteme", s.adminRest.deleteMeRequestCtrl)
|
||||
r.HandleFunc("PUT /verify/{userid}", s.adminRest.setVerifyCtrl)
|
||||
r.HandleFunc("PUT /pin/{id}", s.adminRest.setPinCtrl)
|
||||
r.HandleFunc("GET /blocked", s.adminRest.blockedUsersCtrl)
|
||||
r.HandleFunc("PUT /readonly", s.adminRest.setReadOnlyCtrl)
|
||||
r.HandleFunc("PUT /title/{id}", s.adminRest.setTitleCtrl)
|
||||
})
|
||||
|
||||
// open routes
|
||||
rapi.Group(func(ropen chi.Router) {
|
||||
ropen.Use(middleware.Timeout(30 * time.Second))
|
||||
ropen.Use(rateLimiter(s.openRouteLimiter))
|
||||
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
|
||||
ropen.Get("/config", s.configCtrl)
|
||||
ropen.Get("/find", s.pubRest.findCommentsCtrl)
|
||||
ropen.Get("/id/{id}", s.pubRest.commentByIDCtrl)
|
||||
ropen.Get("/comments", s.pubRest.findUserCommentsCtrl)
|
||||
ropen.Get("/last/{limit}", s.pubRest.lastCommentsCtrl)
|
||||
ropen.Get("/count", s.pubRest.countCtrl)
|
||||
ropen.Post("/counts", s.pubRest.countMultiCtrl)
|
||||
ropen.Get("/list", s.pubRest.listCtrl)
|
||||
ropen.Get("/info", s.pubRest.infoCtrl)
|
||||
// migrator routes deliberately run without R.Timeout: GET /export streams a full-site
|
||||
// backup, GET /wait long-polls for up to 15m, and import/remap ingest large uploads. The
|
||||
// enforcing timeout buffers the whole response and aborts at the deadline, which would
|
||||
// truncate backups, break waiting, and reject large imports.
|
||||
radmin.HandleFunc("GET /export", s.adminRest.migrator.exportCtrl)
|
||||
radmin.HandleFunc("POST /import", s.adminRest.migrator.importCtrl)
|
||||
radmin.HandleFunc("POST /import/form", s.adminRest.migrator.importFormCtrl)
|
||||
radmin.HandleFunc("POST /remap", s.adminRest.migrator.remapCtrl)
|
||||
radmin.HandleFunc("GET /wait", s.adminRest.migrator.waitCtrl)
|
||||
})
|
||||
|
||||
ropen.Route("/rss", func(rrss chi.Router) {
|
||||
rrss.Get("/post", s.rssRest.postCommentsCtrl)
|
||||
rrss.Get("/site", s.rssRest.siteCommentsCtrl)
|
||||
rrss.Get("/reply", s.rssRest.repliesCtrl)
|
||||
})
|
||||
})
|
||||
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
|
||||
rapi.Group().Route(func(rauth *routegroup.Bundle) {
|
||||
rauth.Use(R.Timeout(10 * time.Second))
|
||||
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
|
||||
rauth.Use(R.NoCache, logInfoWithBody)
|
||||
|
||||
// open routes, cached. /img lives here (not in the NoCache group above) because
|
||||
// middleware.NoCache strips If-None-Match from incoming requests, which would
|
||||
// defeat the proxy handler's 304 short-circuit. The handler sets a 30-day
|
||||
// max-age on validated success responses (with a versioned etag for cache
|
||||
// invalidation on revalidation); error responses get Cache-Control: no-store
|
||||
// so transient failures aren't pinned in the cache.
|
||||
rapi.Group(func(ropen chi.Router) {
|
||||
ropen.Use(middleware.Timeout(30 * time.Second))
|
||||
ropen.Use(rateLimiter(10))
|
||||
ropen.Use(authMiddleware.Trace, logInfoWithBody)
|
||||
ropen.Get("/img", s.ImageProxy.Handler)
|
||||
ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl)
|
||||
ropen.Get("/qr/telegram", s.pubRest.telegramQrCtrl)
|
||||
})
|
||||
rauth.HandleFunc("PUT /comment/{id}", s.privRest.updateCommentCtrl)
|
||||
rauth.HandleFunc("POST /preview", s.privRest.previewCommentCtrl)
|
||||
rauth.HandleFunc("POST /comment", s.privRest.createCommentCtrl)
|
||||
rauth.HandleFunc("PUT /vote/{id}", s.privRest.voteCtrl)
|
||||
rauth.With(rejectAnonUser).HandleFunc("POST /deleteme", s.privRest.deleteMeCtrl)
|
||||
rauth.With(rejectAnonUser).HandleFunc("GET /email", s.privRest.getEmailCtrl)
|
||||
rauth.With(rejectAnonUser).HandleFunc("POST /email/subscribe", s.privRest.sendEmailConfirmationCtrl)
|
||||
rauth.With(rejectAnonUser).HandleFunc("POST /email/confirm", s.privRest.setConfirmedEmailCtrl)
|
||||
rauth.With(rejectAnonUser).HandleFunc("DELETE /email", s.privRest.deleteEmailCtrl)
|
||||
rauth.With(rejectAnonUser, rejectHead("GET")).HandleFunc("GET /telegram/subscribe", s.privRest.telegramSubscribeCtrl)
|
||||
rauth.With(rejectAnonUser).HandleFunc("DELETE /telegram", s.privRest.deleteTelegramCtrl)
|
||||
})
|
||||
|
||||
// protected routes, require auth
|
||||
rapi.Group(func(rauth chi.Router) {
|
||||
rauth.Use(middleware.Timeout(30 * time.Second))
|
||||
rauth.Use(rateLimiter(10))
|
||||
rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody)
|
||||
rauth.Get("/user", s.privRest.userInfoCtrl)
|
||||
rauth.Get("/userdata", s.privRest.userAllDataCtrl)
|
||||
})
|
||||
|
||||
// admin routes, require auth and admin users only
|
||||
rapi.Route("/admin", func(radmin chi.Router) {
|
||||
radmin.Use(middleware.Timeout(30 * time.Second))
|
||||
radmin.Use(rateLimiter(10))
|
||||
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
|
||||
radmin.Use(middleware.NoCache, logInfoWithBody)
|
||||
|
||||
radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl)
|
||||
radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl)
|
||||
radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl)
|
||||
radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl)
|
||||
radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl)
|
||||
radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl)
|
||||
radmin.Put("/pin/{id}", s.adminRest.setPinCtrl)
|
||||
radmin.Get("/blocked", s.adminRest.blockedUsersCtrl)
|
||||
radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl)
|
||||
radmin.Put("/title/{id}", s.adminRest.setTitleCtrl)
|
||||
|
||||
// migrator
|
||||
radmin.Get("/export", s.adminRest.migrator.exportCtrl)
|
||||
radmin.Post("/import", s.adminRest.migrator.importCtrl)
|
||||
radmin.Post("/import/form", s.adminRest.migrator.importFormCtrl)
|
||||
radmin.Post("/remap", s.adminRest.migrator.remapCtrl)
|
||||
radmin.Get("/wait", s.adminRest.migrator.waitCtrl)
|
||||
})
|
||||
|
||||
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
|
||||
rapi.Group(func(rauth chi.Router) {
|
||||
rauth.Use(middleware.Timeout(10 * time.Second))
|
||||
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
|
||||
rauth.Use(middleware.NoCache, logInfoWithBody)
|
||||
|
||||
rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl)
|
||||
rauth.Post("/preview", s.privRest.previewCommentCtrl)
|
||||
rauth.Post("/comment", s.privRest.createCommentCtrl)
|
||||
rauth.Put("/vote/{id}", s.privRest.voteCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
|
||||
rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
|
||||
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
|
||||
rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl)
|
||||
rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl)
|
||||
})
|
||||
|
||||
// protected routes, anonymous rejected
|
||||
rapi.Group(func(rauth chi.Router) {
|
||||
rauth.Use(middleware.Timeout(10 * time.Second))
|
||||
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
|
||||
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
|
||||
rauth.Post("/picture", s.privRest.savePictureCtrl)
|
||||
})
|
||||
// protected routes, anonymous rejected
|
||||
rapi.Group().Route(func(rauth *routegroup.Bundle) {
|
||||
rauth.Use(R.Timeout(10 * time.Second))
|
||||
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
|
||||
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
|
||||
rauth.HandleFunc("POST /picture", s.privRest.savePictureCtrl)
|
||||
})
|
||||
|
||||
// open routes on root level
|
||||
router.Group(func(rroot chi.Router) {
|
||||
rroot.Use(middleware.Timeout(10 * time.Second))
|
||||
router.Route(func(rroot *routegroup.Bundle) {
|
||||
rroot.Use(R.Timeout(10 * time.Second))
|
||||
rroot.Use(rateLimiter(50))
|
||||
rroot.Get("/robots.txt", s.pubRest.robotsCtrl)
|
||||
rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||
rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||
rroot.HandleFunc("GET /robots.txt", s.pubRest.robotsCtrl)
|
||||
rroot.With(rejectHead("GET, POST")).HandleFunc("GET /email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||
rroot.HandleFunc("POST /email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||
})
|
||||
|
||||
// file server for static content from s.WebRoot on path /web
|
||||
addFileServer(router, s.WebFS, s.WebRoot, s.Version)
|
||||
// file server for /web: the frontend build first, then the assets embedded in the binary.
|
||||
// the build is embedded under web/ by app/cmd, so that prefix is stripped here. fs.Sub only
|
||||
// fails for an fs.SubFS that refuses, and a nil result would panic on the first request, so
|
||||
// serve nothing from the frontend rather than serving it at the wrong paths
|
||||
embeddedFrontend, err := fs.Sub(s.WebFS, "web")
|
||||
if err != nil {
|
||||
log.Printf("[WARN] no embedded frontend, serving built-in assets only: %v", err)
|
||||
embeddedFrontend = emptyFS{}
|
||||
}
|
||||
addFileServer(router, embeddedFrontend, s.WebRoot, s.Version, s.RemarkURL)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -481,26 +507,36 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
R.RenderJSON(w, cnf)
|
||||
}
|
||||
|
||||
// serves static files from the webRoot directory or files embedded into the compiled binary if that directory is absent
|
||||
func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) {
|
||||
var webFS http.Handler
|
||||
// serves /web from the frontend build, falling back to the assets embedded in the binary for
|
||||
// names the build does not produce. the frontend build is read from webRoot on disk, or from the
|
||||
// copy embedded at app/cmd/web when that directory is absent.
|
||||
func addFileServer(r *routegroup.Bundle, embeddedFrontend fs.FS, webRoot, version, remarkURL string) {
|
||||
frontendFS := embeddedFrontend
|
||||
|
||||
if _, err := os.Stat(webRoot); err == nil {
|
||||
log.Printf("[INFO] run file server from %s from the disk", webRoot)
|
||||
webFS = http.FileServer(http.Dir(webRoot))
|
||||
frontendFS = os.DirFS(webRoot)
|
||||
} else {
|
||||
log.Printf("[INFO] run file server, embedded")
|
||||
var contentFS, _ = fs.Sub(embedFS, "web")
|
||||
webFS = http.FileServer(http.FS(contentFS))
|
||||
}
|
||||
|
||||
webFS = http.StripPrefix("/web", webFS)
|
||||
r.Get("/web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP)
|
||||
// wrapped rather than substituted once at startup: the disk root can change under a running
|
||||
// server, and the docker image has already substituted its copy, where this is a no-op
|
||||
sources := templatedFS{
|
||||
fs: webFiles{frontend: frontendFS, embedded: webassets.FS},
|
||||
remarkURL: remarkURL,
|
||||
}
|
||||
webFS := http.StripPrefix("/web", http.FileServer(http.FS(sources)))
|
||||
r.HandleFunc("GET /web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP)
|
||||
|
||||
r.With(rateLimiter(20),
|
||||
middleware.Timeout(10*time.Second),
|
||||
cacheControl(time.Hour, version),
|
||||
).Get("/web/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
R.Timeout(10*time.Second),
|
||||
// the served body now depends on remarkURL, so it has to be part of the validator. Without
|
||||
// it an operator who corrects a wrong REMARK_URL and restarts the same binary keeps getting
|
||||
// 304 on revalidation, and the client keeps a bundle addressed to the old host for good,
|
||||
// since no-cache means it revalidates rather than aging out
|
||||
cacheControl(time.Hour, version+":"+remarkURL),
|
||||
).HandleFunc("GET /web/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// don't show dirs, just serve files
|
||||
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != ("/web/") {
|
||||
http.NotFound(w, r)
|
||||
@@ -556,192 +592,6 @@ func URLKeyWithUser(r *http.Request) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// rejectAnonUser is a middleware rejecting anonymous users
|
||||
func rejectAnonUser(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := rest.GetUserInfo(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(user.ID, "anonymous_") {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
|
||||
func matchSiteID(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := rest.GetUserInfo(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// skip for basic auth user
|
||||
if user.Name == "admin" && user.ID == "admin" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
// require an explicit site so the user.SiteID check below cannot be bypassed
|
||||
// by simply omitting the query parameter
|
||||
if siteID == "" || user.SiteID != siteID {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
// cacheControl is a middleware setting cache expiration. Using url+version as etag
|
||||
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
|
||||
etag := func(r *http.Request, version string) string {
|
||||
s := version + ":" + r.URL.String()
|
||||
return store.EncodeID(s)
|
||||
}
|
||||
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
e := `"` + etag(r, version) + `"`
|
||||
w.Header().Set("Etag", e)
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
|
||||
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
if strings.Contains(match, e) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
|
||||
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
|
||||
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
|
||||
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
|
||||
// images — none of those should ever execute scripts when rendered, so they get the
|
||||
// strictest policy available as defense-in-depth against future trust-boundary bugs.
|
||||
//
|
||||
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
|
||||
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
|
||||
// inline; filename="image" (framing the response as a file rather than a renderable
|
||||
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
|
||||
// braces: if a future route refactor bypasses this middleware, the image handlers still
|
||||
// emit the policy.
|
||||
func apiCSPMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// securityHeadersMiddleware sets security-related headers:
|
||||
// - Content-Security-Policy: controls which resources the browser is allowed to load
|
||||
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
|
||||
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
|
||||
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
|
||||
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
|
||||
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
|
||||
// and nothing at all on HTTPS→HTTP downgrades
|
||||
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
imgSrc := "*"
|
||||
if imageProxyEnabled {
|
||||
imgSrc = "'self'"
|
||||
}
|
||||
frameAncestors := "*"
|
||||
if len(allowedAncestors) > 0 {
|
||||
frameAncestors = strings.Join(allowedAncestors, " ")
|
||||
}
|
||||
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
|
||||
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
|
||||
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// subscribersOnly is a middleware rejecting non-paid_sub users
|
||||
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
if enable {
|
||||
user, err := rest.GetUserInfo(r)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !user.PaidSub {
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// validEmailAuth is a middleware for auth endpoints for email method.
|
||||
// it rejects login request if user, site or email are suspicious
|
||||
func validEmailAuth() func(http.Handler) http.Handler {
|
||||
|
||||
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
|
||||
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
|
||||
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.URL.Path != "/auth/email/login" {
|
||||
// not email login, skip the check
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if u := r.URL.Query().Get("user"); u != "" {
|
||||
if !reUser.MatchString(u) {
|
||||
log.Printf("[WARN] suspicious user rejected: %s", u)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if a := r.URL.Query().Get("address"); a != "" {
|
||||
if _, err := mail.ParseAddress(a); err != nil {
|
||||
log.Printf("[WARN] suspicious address rejected: %s", a)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if s := r.URL.Query().Get("site"); s != "" {
|
||||
if !reSite.MatchString(s) {
|
||||
log.Printf("[WARN] suspicious site rejected: %s", s)
|
||||
http.Error(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
func parseError(err error, defaultCode int) (code int) {
|
||||
code = defaultCode
|
||||
|
||||
@@ -765,16 +615,3 @@ func parseError(err error, defaultCode int) (code int) {
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
|
||||
// tollbooth v8 requires explicit IP lookup method to be set.
|
||||
// uses RemoteAddr which is set by chi's middleware.RealIP to the real client IP
|
||||
// from X-Forwarded-For, X-Real-IP, or True-Client-IP headers.
|
||||
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
|
||||
lmt := tollbooth.NewLimiter(maxReq, nil)
|
||||
lmt.SetIPLookup(limiter.IPLookup{
|
||||
Name: "RemoteAddr",
|
||||
IndexFromRight: 0,
|
||||
})
|
||||
return tollbooth.HTTPMiddleware(lmt)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
@@ -193,7 +191,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user := rest.MustGetUserInfo(r)
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
id := chi.URLParam(r, "id")
|
||||
id := r.PathValue("id")
|
||||
|
||||
log.Printf("[DEBUG] update comment %s", id)
|
||||
|
||||
@@ -260,7 +258,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
id := chi.URLParam(r, "id")
|
||||
id := r.PathValue("id")
|
||||
log.Printf("[DEBUG] vote for comment %s", id)
|
||||
|
||||
vote := r.URL.Query().Get("vote") == "1"
|
||||
@@ -664,10 +662,8 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return e
|
||||
}
|
||||
|
||||
var merr error
|
||||
merr = multierror.Append(merr, write([]byte(`{"info": `))) // send user prefix
|
||||
merr = multierror.Append(merr, write(userB)) // send user info
|
||||
merr = multierror.Append(merr, write([]byte(`, "comments":`))) // send comments prefix
|
||||
// send user prefix, user info and comments prefix
|
||||
errs := []error{write([]byte(`{"info": `)), write(userB), write([]byte(`, "comments":`))}
|
||||
|
||||
// get comments in 100 in each paginated request
|
||||
for i := range 100 {
|
||||
@@ -682,15 +678,15 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
merr = multierror.Append(merr, write(b))
|
||||
errs = append(errs, write(b))
|
||||
if len(comments) != 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
merr = multierror.Append(merr, write([]byte(`}`)))
|
||||
if merr.(*multierror.Error).ErrorOrNil() != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, merr, "can't write user info", rest.ErrInternal)
|
||||
errs = append(errs, write([]byte(`}`)))
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't write user info", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -709,8 +705,9 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Picture: user.Picture, // carried so the avatar can be removed when the request is processed
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true, // prevents this token from being used for login
|
||||
},
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) {
|
||||
|
||||
assert.Equal(t, false, pngRead, "original image is not yet accessed by server")
|
||||
// retrieve the image from the cache
|
||||
imgURL := strings.Split(strings.Split(string(b), "src=\"")[1], "\"")[0]
|
||||
imgURL, _, _ := strings.Cut(strings.Split(string(b), "src=\"")[1], "\"")
|
||||
// replace srv.RemarkURL with ts.URL
|
||||
imgURL = strings.ReplaceAll(imgURL, srv.RemarkURL, ts.URL)
|
||||
resp, err = http.Get(imgURL)
|
||||
@@ -432,6 +432,7 @@ func TestRest_Update(t *testing.T) {
|
||||
strings.NewReader(`{"text":"updated text", "summary":"my edit"}`))
|
||||
assert.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
beforeUpdate := time.Now()
|
||||
b, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
body, err := io.ReadAll(b.Body)
|
||||
@@ -447,7 +448,7 @@ func TestRest_Update(t *testing.T) {
|
||||
assert.Equal(t, "<p>updated text</p>\n", c2.Text)
|
||||
assert.Equal(t, "updated text", c2.Orig)
|
||||
assert.Equal(t, "my edit", c2.Edit.Summary)
|
||||
assert.True(t, time.Since(c2.Edit.Timestamp) < 1*time.Second)
|
||||
assert.WithinRange(t, c2.Edit.Timestamp, beforeUpdate, time.Now(), "edit stamped during the update")
|
||||
|
||||
// read updated comment
|
||||
res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
||||
@@ -596,7 +597,7 @@ func TestRest_DeleteChildThenParent(t *testing.T) {
|
||||
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, idC2), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
resp, err = sendReq(req, adminUmputunToken)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -784,7 +785,7 @@ func TestRest_Vote(t *testing.T) {
|
||||
req, err := http.NewRequest("GET",
|
||||
fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
resp, err := sendReq(req, adminUmputunToken)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
cr = store.Comment{}
|
||||
@@ -912,6 +913,9 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
{description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
// telegramSubscribeCtrl mutates state, so HEAD (which stdlib ServeMux would route to the
|
||||
// GET handler) must be rejected by rejectHead before it runs
|
||||
{description: "HEAD is rejected on telegram subscribe", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodHead, responseCode: http.StatusMethodNotAllowed},
|
||||
}
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
@@ -971,9 +975,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
parentComment := store.Comment{}
|
||||
require.NoError(t, json.Unmarshal(body, &parentComment))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 1, len(mockDestination.Get()))
|
||||
waitForCount(t, 1, func() int { return len(mockDestination.Get()) })
|
||||
assert.Empty(t, mockDestination.Get()[0].Emails)
|
||||
|
||||
// create child comment from another user, email notification only to admin expected
|
||||
@@ -991,9 +993,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 2, len(mockDestination.Get()))
|
||||
waitForCount(t, 2, func() int { return len(mockDestination.Get()) })
|
||||
assert.Empty(t, mockDestination.Get()[1].Emails)
|
||||
|
||||
// send confirmation token for email
|
||||
@@ -1010,9 +1010,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 1, len(mockDestination.GetVerify()))
|
||||
waitForCount(t, 1, func() int { return len(mockDestination.GetVerify()) })
|
||||
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
|
||||
verificationToken := mockDestination.GetVerify()[0].Token
|
||||
|
||||
@@ -1084,9 +1082,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 3, len(mockDestination.Get()))
|
||||
waitForCount(t, 3, func() int { return len(mockDestination.Get()) })
|
||||
assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails)
|
||||
|
||||
// delete user's email
|
||||
@@ -1114,9 +1110,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 4, len(mockDestination.Get()))
|
||||
waitForCountSettled(t, 4, func() int { return len(mockDestination.Get()) })
|
||||
assert.Empty(t, mockDestination.Get()[3].Emails)
|
||||
|
||||
// confirm email via subscribe call with query params, old behavior, email notification is expected
|
||||
@@ -1133,9 +1127,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 2, len(mockDestination.GetVerify()), "verification email was sent")
|
||||
waitForCount(t, 2, func() int { return len(mockDestination.GetVerify()) }, "verification email was sent")
|
||||
|
||||
// get email user information to verify there is no subscription yet
|
||||
req, err = http.NewRequest(
|
||||
@@ -1170,9 +1162,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 2, len(mockDestination.GetVerify()), "no new verification email was sent")
|
||||
waitForCountSettled(t, 2, func() int { return len(mockDestination.GetVerify()) }, "no new verification email was sent")
|
||||
|
||||
// get email user information to verify the subscription happened without the confirmation call
|
||||
req, err = http.NewRequest(
|
||||
@@ -1221,9 +1211,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
parentComment := store.Comment{}
|
||||
require.NoError(t, json.Unmarshal(body, &parentComment))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 1, len(mockDestination.Get()))
|
||||
waitForCount(t, 1, func() int { return len(mockDestination.Get()) })
|
||||
assert.Empty(t, mockDestination.Get()[0].Telegrams)
|
||||
|
||||
// create child comment from another user, telegram notification only to admin expected
|
||||
@@ -1241,9 +1229,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 2, len(mockDestination.Get()))
|
||||
waitForCount(t, 2, func() int { return len(mockDestination.Get()) })
|
||||
assert.Empty(t, mockDestination.Get()[1].Telegrams)
|
||||
|
||||
// subscribe to telegram while the telegram destination is absent
|
||||
@@ -1354,9 +1340,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 3, len(mockDestination.Get()))
|
||||
waitForCount(t, 3, func() int { return len(mockDestination.Get()) })
|
||||
assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams)
|
||||
|
||||
// delete user's telegram
|
||||
@@ -1384,9 +1368,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 4, len(mockDestination.Get()))
|
||||
waitForCountSettled(t, 4, func() int { return len(mockDestination.Get()) })
|
||||
assert.Empty(t, mockDestination.Get()[3].Telegrams)
|
||||
}
|
||||
|
||||
@@ -1409,7 +1391,7 @@ func TestRest_UserAllData(t *testing.T) {
|
||||
_, err = srv.DataService.Create(c3)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
@@ -1462,7 +1444,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
|
||||
_, err := srv.DataService.Create(c)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
client := &http.Client{Timeout: waitTimeout}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
@@ -1509,6 +1491,8 @@ func TestRest_DeleteMe(t *testing.T) {
|
||||
claims, err := srv.Authenticator.TokenService().Parse(tkn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "provider1_dev", claims.User.ID)
|
||||
assert.Equal(t, "http://example.com/pic.png", claims.User.Picture,
|
||||
"delete_me token must carry the user's picture so the avatar can be removed when the request is processed")
|
||||
assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"])
|
||||
|
||||
req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
|
||||
@@ -1603,7 +1587,9 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
||||
Staging: "/tmp/remark42/images.staging",
|
||||
Location: "/tmp/remark42/images",
|
||||
}, image.ServiceParams{
|
||||
EditDuration: 100 * time.Millisecond,
|
||||
// the "not moved yet" checks below run right after the comment POST returns, so the
|
||||
// commit window has to be wide enough that a stalled runner cannot close it first
|
||||
EditDuration: 3 * time.Second,
|
||||
MaxSize: 2000,
|
||||
ImageAPI: svc.RemarkURL + "/api/v1/picture/",
|
||||
ProxyAPI: svc.RemarkURL + "/api/v1/img",
|
||||
@@ -1666,11 +1652,12 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
||||
assert.Error(t, err, "picture %d not moved from staging yet", i)
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
// the commit runs once EditDuration expires
|
||||
for i := range ids {
|
||||
_, err = os.Stat("/tmp/remark42/images/" + ids[i])
|
||||
assert.NoError(t, err, "picture %d moved from staging and available in permanent location", i)
|
||||
require.Eventually(t, func() bool {
|
||||
_, e := os.Stat("/tmp/remark42/images/" + ids[i])
|
||||
return e == nil
|
||||
}, waitTimeout, pollInterval, "picture %d moved from staging and available in permanent location", i)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
@@ -188,7 +187,7 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.URL.Query().Get("site")
|
||||
log.Printf("[DEBUG] get last comments for %s", siteID)
|
||||
|
||||
limit, err := strconv.Atoi(chi.URLParam(r, "limit"))
|
||||
limit, err := strconv.Atoi(r.PathValue("limit"))
|
||||
if err != nil {
|
||||
limit = 0
|
||||
}
|
||||
@@ -222,7 +221,7 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GET /id/{id}?site=siteID&url=post-url - gets a comment by id
|
||||
func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
id := r.PathValue("id")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
url := r.URL.Query().Get("url")
|
||||
|
||||
@@ -402,7 +401,7 @@ func sendPictureError(w http.ResponseWriter, r *http.Request, status int, err er
|
||||
func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SetImageDefenseHeaders(w)
|
||||
|
||||
user, imgID := chi.URLParam(r, "user"), chi.URLParam(r, "id")
|
||||
user, imgID := r.PathValue("user"), r.PathValue("id")
|
||||
if user == "" || imgID == "" || !safePictureSegment(user) || !safePictureSegment(imgID) {
|
||||
log.Printf("[WARN] rejected picture request with unsafe id segments user=%q id=%q", user, imgID)
|
||||
sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("invalid picture id"), "invalid picture id", rest.ErrAssetNotFound)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -14,9 +15,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/go-pkgz/routegroup"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -381,11 +382,12 @@ func TestRest_Last(t *testing.T) {
|
||||
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
|
||||
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
|
||||
|
||||
// add 3 comments
|
||||
// add 3 comments, with the clock pushed past a millisecond boundary in between so the two
|
||||
// "since" values below are distinct
|
||||
ts1 := time.Now().UnixNano() / 1000000
|
||||
addComment(t, c1, ts)
|
||||
id1 := addComment(t, c1, ts)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
waitPastMillisecond(time.Now())
|
||||
ts2 := time.Now().UnixNano() / 1000000
|
||||
id2 := addComment(t, c2, ts)
|
||||
|
||||
@@ -539,9 +541,17 @@ func TestRest_FindUserComments_CWE_918(t *testing.T) {
|
||||
assert.Equal(t, arbitraryServer.URL, resp.Comments[0].Locator.URL, "arbitrary URL provided by the request")
|
||||
}
|
||||
|
||||
// waitPastMillisecond blocks until the wall clock moves past ts's millisecond, so whatever is
|
||||
// created next gets a distinct value for the millisecond-precision "since" filter
|
||||
func waitPastMillisecond(ts time.Time) {
|
||||
next := ts.Truncate(time.Millisecond).Add(time.Millisecond)
|
||||
time.Sleep(time.Until(next) + time.Microsecond) // a non-positive duration returns at once
|
||||
}
|
||||
|
||||
func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
|
||||
// test that comment counting is consistent between tree and plain formats
|
||||
ts, srv, teardown := startupT(t)
|
||||
// the open-route limit is lifted so the subtests below can run back to back
|
||||
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.openRouteLimiter = 100000 })
|
||||
defer teardown()
|
||||
|
||||
commentLocator := store.Locator{URL: "test-url", SiteID: "remark42"}
|
||||
@@ -567,55 +577,55 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
|
||||
}
|
||||
|
||||
// adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post.
|
||||
// with sleep so that at least few millisecond pass between each comment
|
||||
// and later we would be able to use that in "since" filter with millisecond precision
|
||||
// each comment waits for the clock to pass the previous one's millisecond so the "since"
|
||||
// filter, which has millisecond precision, can tell them apart
|
||||
ids := make([]string, 9)
|
||||
timestamps := make([]time.Time, 9)
|
||||
c1 := store.Comment{Text: "top-level comment 1", Locator: commentLocator}
|
||||
ids[0], timestamps[0] = addCommentGetCreatedTime(t, c1, ts)
|
||||
// #3 by score
|
||||
setScore(commentLocator, ids[0], 1)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[0])
|
||||
|
||||
c2 := store.Comment{Text: "top-level comment 2", Locator: commentLocator}
|
||||
ids[1], timestamps[1] = addCommentGetCreatedTime(t, c2, ts)
|
||||
// #2 by score
|
||||
setScore(commentLocator, ids[1], 2)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[1])
|
||||
|
||||
c3 := store.Comment{Text: "second-level comment 1", ParentID: ids[0], Locator: commentLocator}
|
||||
ids[2], timestamps[2] = addCommentGetCreatedTime(t, c3, ts)
|
||||
// #1 by score
|
||||
setScore(commentLocator, ids[2], 10)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[2])
|
||||
|
||||
c4 := store.Comment{Text: "third-level comment 1", ParentID: ids[2], Locator: commentLocator}
|
||||
ids[3], timestamps[3] = addCommentGetCreatedTime(t, c4, ts)
|
||||
// #5 by score, #1 by controversy
|
||||
setScore(commentLocator, ids[3], 4)
|
||||
setScore(commentLocator, ids[3], -4)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[3])
|
||||
|
||||
c5 := store.Comment{Text: "second-level comment 2", ParentID: ids[1], Locator: commentLocator}
|
||||
ids[4], timestamps[4] = addCommentGetCreatedTime(t, c5, ts)
|
||||
// #5 by score, #2 by controversy
|
||||
setScore(commentLocator, ids[4], 2)
|
||||
setScore(commentLocator, ids[4], -3)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[4])
|
||||
|
||||
c6 := store.Comment{Text: "deleted third-level comment 2", ParentID: ids[4], Locator: commentLocator}
|
||||
ids[5], timestamps[5] = addCommentGetCreatedTime(t, c6, ts)
|
||||
// deleted later so not visible in site-wide requests
|
||||
setScore(commentLocator, ids[5], 10)
|
||||
setScore(commentLocator, ids[5], -10)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[5])
|
||||
|
||||
c7 := store.Comment{Text: "top-level comment 3", Locator: commentLocator}
|
||||
ids[6], timestamps[6] = addCommentGetCreatedTime(t, c7, ts)
|
||||
// #6 by score, #4 by controversy
|
||||
setScore(commentLocator, ids[6], -3)
|
||||
setScore(commentLocator, ids[6], 1)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
waitPastMillisecond(timestamps[6])
|
||||
|
||||
c8 := store.Comment{Text: "deleted second-level comment 3", ParentID: ids[6], Locator: commentLocator}
|
||||
ids[7], timestamps[7] = addCommentGetCreatedTime(t, c8, ts)
|
||||
@@ -733,16 +743,16 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
|
||||
{"format=tree&limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`},
|
||||
{"format=tree&offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`},
|
||||
{"format=tree&limit=2", `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{"format=tree&limit=6", `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]},
|
||||
{"format=tree&limit=7", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"format=tree&limit=6", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"format=tree&limit=7", `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||
{"format=tree&url=test-url&limit=2", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{"format=tree&url=test-url&limit=6", `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]},
|
||||
{"format=tree&url=test-url&limit=6", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||
{"format=tree&url=test-url&limit=7", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||
// start after first top-level comment
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[0]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[0]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]},
|
||||
// start after second top-level comment
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[1]), `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[1]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[1]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||
// start after third top-level comment, so expect comment to post 2, or no comments on post 1 if "url" is set
|
||||
{fmt.Sprintf("format=tree&limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||
@@ -771,15 +781,17 @@ func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
|
||||
t.Run(tc.params, func(t *testing.T) {
|
||||
url := fmt.Sprintf(ts.URL+"/api/v1/find?site=remark42&%s", tc.params)
|
||||
body, code := get(t, url)
|
||||
// bad-request cases are identified by their error response body rather than
|
||||
// a "=bad" substring of the params: comment IDs are random UUIDs and one
|
||||
// starting with "bad" (e.g. offset_id=bad49e60-...) would otherwise be
|
||||
// misread as a bad request, making this test flaky.
|
||||
expectedStatus := http.StatusOK
|
||||
if strings.Contains(tc.params, "=bad") {
|
||||
if strings.Contains(tc.expectedBody, `"error":`) {
|
||||
expectedStatus = http.StatusBadRequest
|
||||
}
|
||||
assert.Equal(t, expectedStatus, code)
|
||||
assert.Contains(t, body, tc.expectedBody)
|
||||
t.Log(body)
|
||||
// prevent hit limiter from engaging
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -975,13 +987,26 @@ func TestRest_QR(t *testing.T) {
|
||||
assert.Equal(t, "image/png", r.Header.Get("Content-Type"))
|
||||
assert.Equal(t, http.StatusOK, r.StatusCode)
|
||||
|
||||
// compare the image
|
||||
// compare the decoded image rather than the encoded bytes: the pixels are what the endpoint
|
||||
// promises, while the byte stream is whatever the toolchain's png encoder produces, and
|
||||
// pinning that fails on a go release that changes it
|
||||
fh, err := os.Open("testdata/qr_test.png")
|
||||
defer func() { assert.NoError(t, fh.Close()) }()
|
||||
assert.NoError(t, err)
|
||||
img, err := io.ReadAll(fh)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, img, bdy)
|
||||
require.NoError(t, err)
|
||||
|
||||
want, err := png.Decode(fh)
|
||||
require.NoError(t, err)
|
||||
got, err := png.Decode(bytes.NewReader(bdy))
|
||||
require.NoError(t, err, "the endpoint did not return a decodable png")
|
||||
|
||||
require.Equal(t, want.Bounds(), got.Bounds(), "the qr code is not the size it used to be")
|
||||
for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ {
|
||||
for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ {
|
||||
if want.At(x, y) != got.At(x, y) {
|
||||
t.Fatalf("the qr code differs at %d,%d: want %v, got %v", x, y, want.At(x, y), got.At(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_Info(t *testing.T) {
|
||||
@@ -1044,12 +1069,20 @@ func TestRest_LoadPictureRejectsPathTraversal(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
}{
|
||||
{name: "dotdot in user segment", path: "/api/v1/picture/../remark.db"},
|
||||
{name: "dotdot in id segment", path: "/api/v1/picture/dev_user/..%2Fremark.db"},
|
||||
{name: "encoded dotdot in user segment", path: "/api/v1/picture/%2E%2E/remark.db"},
|
||||
// A literal ".." is normalized away by net/http.ServeMux before routing: the request
|
||||
// is redirected to the cleaned path, which matches no picture route, so it never reaches
|
||||
// loadPictureCtrl and resolves to 404. The traversal is neutralized at the router level
|
||||
// (the cleaned path can only ever reach defined routes or the webRoot-bounded file server),
|
||||
// so nothing is served either way.
|
||||
{name: "dotdot in user segment", path: "/api/v1/picture/../remark.db", wantStatus: http.StatusNotFound},
|
||||
// Encoded traversal is not cleaned by the router, so the handler's safePictureSegment
|
||||
// validation is what rejects it, with 400.
|
||||
{name: "dotdot in id segment", path: "/api/v1/picture/dev_user/..%2Fremark.db", wantStatus: http.StatusBadRequest},
|
||||
{name: "encoded dotdot in user segment", path: "/api/v1/picture/%2E%2E/remark.db", wantStatus: http.StatusBadRequest},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -1059,7 +1092,7 @@ func TestRest_LoadPictureRejectsPathTraversal(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(t, c.wantStatus, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
@@ -1193,8 +1226,8 @@ func TestRest_LoadPictureRejectsNonImage(t *testing.T) {
|
||||
// (other fields like dataService, cache, commentFormatter are not touched here).
|
||||
p := &public{imageService: image.NewService(&imageStore, image.ServiceParams{})}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Get("/api/v1/picture/{user}/{id}", p.loadPictureCtrl)
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
router.HandleFunc("GET /api/v1/picture/{user}/{id}", p.loadPictureCtrl)
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
+385
-275
@@ -4,16 +4,19 @@ import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
@@ -22,6 +25,7 @@ import (
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/go-pkgz/routegroup"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
@@ -36,6 +40,7 @@ import (
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/webassets"
|
||||
)
|
||||
|
||||
// To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test
|
||||
@@ -68,22 +73,319 @@ func TestRest_FileServer(t *testing.T) {
|
||||
_ = os.Remove(testHTMLFile)
|
||||
}
|
||||
|
||||
// TestRest_FileServerStaticAssets covers the static file server behaviors that are
|
||||
// sensitive to the router: the bare /web -> /web/ redirect, cache headers applied to
|
||||
// served assets, 404 for missing files, and the directory-listing block.
|
||||
func TestRest_FileServerStaticAssets(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
require.NoError(t, os.WriteFile(srv.WebRoot+"/asset-test.html", []byte("static body"), 0o600))
|
||||
require.NoError(t, os.MkdirAll(srv.WebRoot+"/subdir-test", 0o700))
|
||||
defer func() {
|
||||
_ = os.Remove(srv.WebRoot + "/asset-test.html")
|
||||
_ = os.RemoveAll(srv.WebRoot + "/subdir-test")
|
||||
}()
|
||||
|
||||
noRedirect := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
defer noRedirect.CloseIdleConnections()
|
||||
|
||||
t.Run("bare /web redirects to /web/", func(t *testing.T) {
|
||||
resp, err := noRedirect.Get(ts.URL + "/web")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode)
|
||||
assert.Equal(t, "/web/", resp.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("serves an existing asset with cache headers", func(t *testing.T) {
|
||||
resp, err := noRedirect.Get(ts.URL + "/web/asset-test.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "static body", string(body))
|
||||
assert.NotEmpty(t, resp.Header.Get("Etag"), "cacheControl must set an Etag on served assets")
|
||||
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age", "cacheControl must set max-age on served assets")
|
||||
})
|
||||
|
||||
t.Run("missing asset returns 404", func(t *testing.T) {
|
||||
_, code := get(t, ts.URL+"/web/does-not-exist.html")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
})
|
||||
|
||||
t.Run("directory listing is blocked", func(t *testing.T) {
|
||||
resp, err := noRedirect.Get(ts.URL + "/web/subdir-test/")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "directory listings must be blocked")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRest_FileServerBackendAssets covers the assets embedded in the binary and the rule that a
|
||||
// name the frontend build provides is served from there instead. WebRoot is a fresh empty
|
||||
// directory so the frontend side is known, rather than the shared temp dir startupT defaults to.
|
||||
func TestRest_FileServerBackendAssets(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.WebRoot = t.TempDir() })
|
||||
defer teardown()
|
||||
|
||||
t.Run("serves every embedded asset byte for byte", func(t *testing.T) {
|
||||
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
want, err := fs.ReadFile(webassets.FS, name)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, code := get(t, ts.URL+"/web/"+name)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, string(want), body, "the bytes must come from the embedded assets")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serves the image with its own content type", func(t *testing.T) {
|
||||
resp, err := http.Get(ts.URL + "/web/400x400.jpeg")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"))
|
||||
})
|
||||
|
||||
t.Run("head is served", func(t *testing.T) {
|
||||
resp, err := http.Head(ts.URL + "/web/privacy.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("frontend output wins over the embedded copy", func(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(srv.WebRoot+"/privacy.html", []byte("operator's own policy"), 0o600))
|
||||
t.Cleanup(func() { _ = os.Remove(srv.WebRoot + "/privacy.html") })
|
||||
|
||||
body, code := get(t, ts.URL+"/web/privacy.html")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, "operator's own policy", body)
|
||||
})
|
||||
|
||||
t.Run("traversal out of the asset root is refused", func(t *testing.T) {
|
||||
for _, p := range []string{"/web/../../etc/passwd", "/web/..%2f..%2fetc%2fpasswd", "/web/%2e%2e/%2e%2e/etc/passwd"} {
|
||||
t.Run(p, func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+p)
|
||||
assert.NotContains(t, body, "root:", "must never serve a file outside the served roots")
|
||||
assert.NotEqual(t, http.StatusInternalServerError, code, "a rejected name must not surface as 500")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing in both still returns 404", func(t *testing.T) {
|
||||
_, code := get(t, ts.URL+"/web/neither-source-has-this.html")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRest_FileServerEmbeddedFrontend covers the branch taken when no web root exists on disk,
|
||||
// which is how the released binary runs. The frontend stands in for the copy embedded at
|
||||
// app/cmd/web, so a name it provides and a name only the assets provide are both exercised.
|
||||
func TestRest_FileServerEmbeddedFrontend(t *testing.T) {
|
||||
frontend := fstest.MapFS{"index.html": {Data: []byte("embedded frontend index")}}
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com")
|
||||
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("serves the embedded frontend", func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+"/web/index.html")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, "embedded frontend index", body)
|
||||
})
|
||||
|
||||
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
|
||||
t.Run("falls back to "+name, func(t *testing.T) {
|
||||
want, err := fs.ReadFile(webassets.FS, name)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, code := get(t, ts.URL+"/web/"+name)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, string(want), body)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a name neither source has is missing", func(t *testing.T) {
|
||||
_, code := get(t, ts.URL+"/web/nothing-here.html")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
})
|
||||
|
||||
t.Run("a name the operating system rejects is missing, not an error", func(t *testing.T) {
|
||||
_, code := get(t, ts.URL+"/web/a%00b.html")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRest_FileServerRoutesEmbedded drives the whole router the released binary runs: no web root
|
||||
// on disk, and the frontend read from WebFS. It is what pins the web/ prefix routes() strips, which
|
||||
// a test calling addFileServer directly cannot see.
|
||||
func TestRest_FileServerRoutesEmbedded(t *testing.T) {
|
||||
frontend := fstest.MapFS{
|
||||
"web/index.html": {Data: []byte("embedded index")},
|
||||
"web/iframe.html": {Data: []byte("embedded iframe")},
|
||||
"web/remark.mjs": {Data: []byte("embedded bundle")},
|
||||
}
|
||||
ts, _, teardown := startupT(t, func(srv *Rest) {
|
||||
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
|
||||
srv.WebFS = frontend
|
||||
})
|
||||
defer teardown()
|
||||
|
||||
t.Run("serves the frontend from under the web prefix", func(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"index.html": "embedded index",
|
||||
"iframe.html": "embedded iframe",
|
||||
"remark.mjs": "embedded bundle",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+"/web/"+name)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, want, body)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the prefix is stripped rather than exposed", func(t *testing.T) {
|
||||
_, code := get(t, ts.URL+"/web/web/index.html")
|
||||
assert.Equal(t, http.StatusNotFound, code, "the web/ prefix must not be reachable as a path")
|
||||
})
|
||||
|
||||
t.Run("the embedded assets still answer alongside it", func(t *testing.T) {
|
||||
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, code := get(t, ts.URL+"/web/privacy.html")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, string(want), body)
|
||||
})
|
||||
}
|
||||
|
||||
// refusingSubFS is an fs.FS whose Sub refuses, which is the only way fs.Sub returns a nil
|
||||
// filesystem. routes() has to survive it, since a nil frontend would panic on the first request.
|
||||
type refusingSubFS struct{}
|
||||
|
||||
func (refusingSubFS) Open(name string) (fs.File, error) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
func (refusingSubFS) Sub(string) (fs.FS, error) { return nil, errors.New("refused") }
|
||||
|
||||
// TestRest_FileServerFrontendSourceRefused covers the branch where the frontend source cannot be
|
||||
// sub-rooted: /web must keep serving the embedded assets rather than panicking.
|
||||
func TestRest_FileServerFrontendSourceRefused(t *testing.T) {
|
||||
ts, _, teardown := startupT(t, func(srv *Rest) {
|
||||
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
|
||||
srv.WebFS = refusingSubFS{}
|
||||
})
|
||||
defer teardown()
|
||||
|
||||
t.Run("the embedded assets still serve", func(t *testing.T) {
|
||||
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
||||
require.NoError(t, err)
|
||||
|
||||
body, code := get(t, ts.URL+"/web/privacy.html")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, string(want), body)
|
||||
})
|
||||
|
||||
t.Run("a frontend name is missing rather than fatal", func(t *testing.T) {
|
||||
_, code := get(t, ts.URL+"/web/iframe.html")
|
||||
assert.Equal(t, http.StatusNotFound, code)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRest_RejectHeadOnDestructiveGET verifies that HEAD is blocked on the state-mutating
|
||||
// GET routes (which stdlib http.ServeMux would otherwise route to the GET handler) while
|
||||
// still being served for safe, read-only routes.
|
||||
func TestRest_RejectHeadOnDestructiveGET(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
t.Run("HEAD is rejected on a destructive GET route", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodHead, ts.URL+"/api/v1/admin/deleteme?site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach a state-mutating GET handler")
|
||||
assert.Equal(t, "GET", resp.Header.Get("Allow"), "405 must carry an Allow header")
|
||||
})
|
||||
|
||||
t.Run("HEAD is rejected on the email unsubscribe route", func(t *testing.T) {
|
||||
// emailUnsubscribeCtrl deletes the user's email subscription on GET, so HEAD (which
|
||||
// ServeMux would route to the GET handler) must be rejected before it runs
|
||||
resp, err := client.Head(ts.URL + "/email/unsubscribe.html?site=remark42")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach the email-unsubscribe handler")
|
||||
assert.Equal(t, "GET, POST", resp.Header.Get("Allow"), "Allow must list every method the resource supports")
|
||||
})
|
||||
|
||||
t.Run("HEAD still works on a safe read-only route", func(t *testing.T) {
|
||||
resp, err := client.Head(ts.URL + "/api/v1/config?site=remark42")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD must still be served for safe read-only routes")
|
||||
})
|
||||
|
||||
t.Run("wrong method on a known route returns 405 with Allow", func(t *testing.T) {
|
||||
// method-in-pattern is new under ServeMux; a wrong method on a known route must
|
||||
// still yield 405 with the allowed methods advertised
|
||||
resp, err := client.Post(ts.URL+"/api/v1/config?site=remark42", "application/json", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Allow"), "GET", "405 must advertise the allowed methods")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRest_AvatarMounts verifies both avatar mounts (root /avatar/ and /api/v1/avatar/)
|
||||
// still route to the avatar handler after the chi Mount -> ServeMux Handle rewiring,
|
||||
// rather than falling through to a router 404.
|
||||
func TestRest_AvatarMounts(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
for _, path := range []string{"/api/v1/avatar/nonexistent.image", "/avatar/nonexistent.image"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+path)
|
||||
// the avatar handler responds (403 "can't load avatar"), not a router 404
|
||||
assert.Equal(t, http.StatusForbidden, code, "avatar mount must reach the avatar handler")
|
||||
assert.Contains(t, body, "can't load avatar", "request must reach the avatar handler, not a routing 404")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_Shutdown(t *testing.T) {
|
||||
srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}}
|
||||
port := chooseUnusedPort(t)
|
||||
done := make(chan bool)
|
||||
|
||||
// without waiting for channel close at the end goroutine will stay alive after test finish
|
||||
// which would create data race with next test
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
srv.Shutdown()
|
||||
srv.Run("127.0.0.1", port)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
st := time.Now()
|
||||
srv.Run("127.0.0.1", 0)
|
||||
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
|
||||
<-done
|
||||
defer srv.Shutdown() // a failed readiness wait must not leave srv.Run behind for goleak
|
||||
waitForServerStart(t, port)
|
||||
srv.Shutdown()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(serverStopTimeout):
|
||||
t.Fatal("rest server did not stop after Shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_filterComments(t *testing.T) {
|
||||
@@ -102,7 +404,7 @@ func TestRest_filterComments(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRest_RunStaticSSLMode(t *testing.T) {
|
||||
sslPort := chooseRandomUnusedPort()
|
||||
sslPort := chooseUnusedPort(t)
|
||||
srv := Rest{
|
||||
Authenticator: auth.NewService(auth.Opts{
|
||||
AvatarStore: avatar.NewLocalFS("/tmp"),
|
||||
@@ -119,12 +421,12 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
|
||||
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
||||
}
|
||||
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
go func() {
|
||||
srv.Run("", port)
|
||||
}()
|
||||
|
||||
waitForHTTPSServerStart(sslPort)
|
||||
waitForServerStart(t, sslPort, port)
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
@@ -157,7 +459,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
||||
sslPort := chooseRandomUnusedPort()
|
||||
sslPort := chooseUnusedPort(t)
|
||||
srv := Rest{
|
||||
Authenticator: &auth.Service{},
|
||||
ImageProxy: &proxy.Image{},
|
||||
@@ -168,13 +470,13 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
||||
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
||||
}
|
||||
|
||||
port := chooseRandomUnusedPort()
|
||||
port := chooseUnusedPort(t)
|
||||
go func() {
|
||||
// can't check https server locally, just only http server
|
||||
srv.Run("", port)
|
||||
}()
|
||||
|
||||
waitForHTTPSServerStart(sslPort)
|
||||
waitForServerStart(t, sslPort, port)
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
@@ -193,28 +495,6 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
||||
srv.Shutdown()
|
||||
}
|
||||
|
||||
func TestRest_rejectAnonUser(t *testing.T) {
|
||||
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprintln(w, "Hello")
|
||||
}))))
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
|
||||
|
||||
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
|
||||
|
||||
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
|
||||
}
|
||||
|
||||
func Test_URLKey(t *testing.T) {
|
||||
tbl := []struct {
|
||||
url string
|
||||
@@ -284,37 +564,6 @@ func TestRest_parseError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_cacheControl(t *testing.T) {
|
||||
tbl := []struct {
|
||||
url string
|
||||
version string
|
||||
exp time.Duration
|
||||
etag string
|
||||
maxAge int
|
||||
}{
|
||||
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
|
||||
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
|
||||
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
|
||||
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.url, http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
t.Logf("%+v", resp.Header)
|
||||
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
|
||||
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_frameAncestors(t *testing.T) {
|
||||
ts, _, teardown := startupT(t, func(o *Rest) {
|
||||
o.AllowedAncestors = []string{"'self'", "https://example.com"}
|
||||
@@ -324,9 +573,12 @@ func TestRest_frameAncestors(t *testing.T) {
|
||||
client := http.Client{}
|
||||
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
|
||||
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
|
||||
// until the test ends, so the body has to be released before the server is torn down here
|
||||
require.NoError(t, resp.Body.Close())
|
||||
client.CloseIdleConnections()
|
||||
teardown()
|
||||
|
||||
// test case without frame-ancestors
|
||||
@@ -341,157 +593,11 @@ func TestRest_frameAncestors(t *testing.T) {
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;")
|
||||
}
|
||||
|
||||
// TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none'
|
||||
// override regardless of what the global CSP allows. The widget HTML pages
|
||||
// (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap),
|
||||
// so the test asserts the two policies diverge across origins.
|
||||
func TestRest_apiCSP(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
client := http.Client{}
|
||||
|
||||
// JSON API endpoint — must carry the strict policy
|
||||
resp, err := client.Get(ts.URL + "/api/v1/config")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp, "default-src 'none'",
|
||||
"API responses must override the global CSP with default-src 'none'; got %q", csp)
|
||||
assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp)
|
||||
assert.NotContains(t, csp, "'unsafe-inline'",
|
||||
"API CSP must not allow inline scripts/styles; got %q", csp)
|
||||
|
||||
// RSS/XML endpoint — same strict policy, and the XML response itself must still be served
|
||||
respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42")
|
||||
require.NoError(t, err)
|
||||
defer respRSS.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP")
|
||||
cspRSS := respRSS.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP")
|
||||
assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox")
|
||||
|
||||
// widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap)
|
||||
resp2, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
csp2 := resp2.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp2, "'unsafe-inline'",
|
||||
"widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2)
|
||||
}
|
||||
|
||||
// check CSP, img-src should be 'self' with proxy enabled and * without it
|
||||
func TestRest_securityHeaders(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
|
||||
// with proxy disabled
|
||||
client := http.Client{}
|
||||
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
teardown()
|
||||
|
||||
// check CSP with proxy enabled
|
||||
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||
srv.ExternalImageProxy = true
|
||||
})
|
||||
defer teardown()
|
||||
resp, err = client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
}
|
||||
|
||||
func TestRest_subscribersOnly(t *testing.T) {
|
||||
paidSubUser := &token.User{}
|
||||
paidSubUser.SetPaidSub(true)
|
||||
|
||||
tbl := []struct {
|
||||
subsOnly bool
|
||||
user token.User
|
||||
setUser bool
|
||||
status int
|
||||
}{
|
||||
{true, token.User{}, false, http.StatusUnauthorized},
|
||||
{true, token.User{}, true, http.StatusForbidden},
|
||||
{false, token.User{}, false, http.StatusOK},
|
||||
{false, token.User{}, true, http.StatusOK},
|
||||
{true, *paidSubUser, true, http.StatusOK},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
|
||||
if tt.setUser {
|
||||
req = token.SetUserInfo(req, tt.user)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, tt.status, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_validEmailAuth(t *testing.T) {
|
||||
tbl := []struct {
|
||||
req string
|
||||
status int
|
||||
}{
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden},
|
||||
{"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK},
|
||||
{"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden},
|
||||
{"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden},
|
||||
{"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, tt.status, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// randomPath pick a file or folder name which is not in use for sure
|
||||
func randomPath(tempDir, basename, suffix string) (string, error) {
|
||||
for range 10 {
|
||||
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
|
||||
fmt.Printf("fname %q", fname)
|
||||
_, err := os.Stat(fname)
|
||||
if err != nil {
|
||||
return fname, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot create temp file in %s", tempDir)
|
||||
}
|
||||
|
||||
// startupT runs fully configured testing server
|
||||
// srvHook is an optional func to set some Rest param after the creation but prior to Run
|
||||
func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, srv *Rest, teardown func()) {
|
||||
tmp := os.TempDir()
|
||||
testDB, err := randomPath(tmp, "test-remark", ".db")
|
||||
require.NoError(t, err)
|
||||
testDB := filepath.Join(t.TempDir(), "test-remark.db") // per-test dir, removed when the test ends
|
||||
|
||||
_ = os.RemoveAll(tmp + "/ava-remark42")
|
||||
_ = os.RemoveAll(tmp + "/pics-remark42")
|
||||
@@ -572,7 +678,6 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
||||
teardown = func() {
|
||||
ts.Close()
|
||||
require.NoError(t, srv.DataService.Close())
|
||||
_ = os.Remove(testDB)
|
||||
_ = os.RemoveAll(tmp + "/ava-remark42")
|
||||
_ = os.RemoveAll(tmp + "/pics-remark42")
|
||||
}
|
||||
@@ -580,6 +685,44 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
||||
return ts, srv, teardown
|
||||
}
|
||||
|
||||
const (
|
||||
// outer bound before a wait is called a hang, generous enough for a loaded CI runner
|
||||
waitTimeout = 30 * time.Second
|
||||
pollInterval = 10 * time.Millisecond
|
||||
|
||||
// budget for a server to stop once asked, tight enough to catch a shutdown that hangs
|
||||
serverStopTimeout = 10 * time.Second
|
||||
|
||||
// connect budget for a single probe, kept off the poll interval so a slow loopback connect
|
||||
// on a loaded runner does not look like a server that is not listening
|
||||
probeDialTimeout = time.Second
|
||||
|
||||
// window to prove something did not happen
|
||||
notifySettle = 300 * time.Millisecond
|
||||
|
||||
// poll interval for waits that issue an HTTP request. the admin routes allow 10 req/s and
|
||||
// the open ones 100 in tests, so this stays below the tighter of the two and the poll
|
||||
// cannot manufacture the 429s it would then have to interpret
|
||||
httpPoll = 150 * time.Millisecond
|
||||
)
|
||||
|
||||
// waitForCount blocks until got reaches want, failing the test with the last value it saw.
|
||||
// for work that is delivered asynchronously, such as notifications reaching a mock destination
|
||||
func waitForCount(t *testing.T, want int, got func() int, msgAndArgs ...any) {
|
||||
t.Helper()
|
||||
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||
assert.Equal(c, want, got(), msgAndArgs...)
|
||||
}, waitTimeout, pollInterval)
|
||||
}
|
||||
|
||||
// waitForCountSettled waits for got to reach want and then holds it there, so a delivery
|
||||
// arriving late is caught rather than passing because the count was read the instant it matched
|
||||
func waitForCountSettled(t *testing.T, want int, got func() int, msgAndArgs ...any) {
|
||||
t.Helper()
|
||||
waitForCount(t, want, got, msgAndArgs...)
|
||||
require.Never(t, func() bool { return got() != want }, notifySettle, pollInterval, msgAndArgs...)
|
||||
}
|
||||
|
||||
// fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name
|
||||
func fakeAuth(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -603,7 +746,7 @@ func get(t *testing.T, url string) (response string, statusCode int) {
|
||||
return string(body), r.StatusCode
|
||||
}
|
||||
|
||||
func sendReq(_ *testing.T, r *http.Request, tkn string) (*http.Response, error) {
|
||||
func sendReq(r *http.Request, tkn string) (*http.Response, error) {
|
||||
client := http.Client{Timeout: 5 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
if tkn != "" {
|
||||
@@ -685,7 +828,6 @@ func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server
|
||||
crResp := R.JSON{}
|
||||
err = json.Unmarshal(b, &crResp)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Nanosecond * 10)
|
||||
created, err = time.Parse(time.RFC3339, crResp["time"].(string))
|
||||
require.NoError(t, err)
|
||||
return crResp["id"].(string), created
|
||||
@@ -697,37 +839,41 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
||||
}
|
||||
|
||||
func requireAdminOnly(t *testing.T, req *http.Request) {
|
||||
resp, err := sendReq(t, req, "") // no-auth user
|
||||
resp, err := sendReq(req, "") // no-auth user
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
|
||||
resp, err = sendReq(t, req, devToken) // non-admin user
|
||||
resp, err = sendReq(req, devToken) // non-admin user
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||
// collision between concurrently running package test binaries very unlikely
|
||||
func chooseUnusedPort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", ":0")
|
||||
require.NoError(t, err, "no free port available")
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
require.NoError(t, ln.Close())
|
||||
return port
|
||||
}
|
||||
|
||||
func waitForHTTPSServerStart(port int) {
|
||||
// wait for up to 3 seconds for HTTPS server to start
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
||||
if conn != nil {
|
||||
// waitForServerStart blocks until something accepts on every listed port, failing the test
|
||||
// naming the port that never came up
|
||||
func waitForServerStart(t *testing.T, ports ...int) {
|
||||
t.Helper()
|
||||
for _, port := range ports {
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), probeDialTimeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
break
|
||||
}
|
||||
return true
|
||||
}, waitTimeout, pollInterval, "server on port %d didn't start", port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -736,45 +882,9 @@ func TestMain(m *testing.M) {
|
||||
m,
|
||||
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
||||
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
||||
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
||||
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRest_matchSiteID reproduces the multi-tenant isolation gap in the matchSiteID
|
||||
// middleware. Before the fix, the check `if siteID != "" && user.SiteID != siteID`
|
||||
// silently allowed any authenticated request that omitted the ?site= query param.
|
||||
// On admin and user-mutation routes this meant the cross-site check was bypassable
|
||||
// just by dropping the parameter. The fix requires ?site= to be present and to match
|
||||
// the user's bound site.
|
||||
func TestRest_matchSiteID(t *testing.T) {
|
||||
wrapped := matchSiteID(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
userSite string
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{name: "matching site allowed", userSite: "site-a", query: "?site=site-a", want: http.StatusOK},
|
||||
{name: "mismatched site forbidden", userSite: "site-a", query: "?site=site-b", want: http.StatusForbidden},
|
||||
{name: "missing site param rejected", userSite: "site-a", query: "", want: http.StatusForbidden},
|
||||
{name: "empty site param rejected", userSite: "site-a", query: "?site=", want: http.StatusForbidden},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = rest.SetUserInfo(r, store.User{ID: "u", Name: "u", SiteID: c.userSite})
|
||||
wrapped.ServeHTTP(w, r)
|
||||
})
|
||||
ts := httptest.NewServer(h)
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + c.query)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, c.want, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,29 @@ import (
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// rssPubTime returns a second-aligned base timestamp and formats it the way the feed does, so
|
||||
// comment pubDates are pinned rather than dependent on when in the second the test runs.
|
||||
func rssPubTime() (base time.Time, pubDate string) {
|
||||
base = time.Now().Truncate(time.Second)
|
||||
return base, base.Format(time.RFC1123Z)
|
||||
}
|
||||
|
||||
func TestServer_RssPost(t *testing.T) {
|
||||
ts, rst, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
waitOnSecChange()
|
||||
base, pubDate := rssPubTime()
|
||||
|
||||
c1 := store.Comment{
|
||||
ID: "1234567890",
|
||||
Text: "test 123",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
ID: "1234567890",
|
||||
Text: "test 123",
|
||||
Timestamp: base,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
}
|
||||
id1, err := rst.DataService.Create(c1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1234567890", id1)
|
||||
pubDate := time.Now().Format(time.RFC1123Z)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah1")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
@@ -63,21 +70,21 @@ func TestServer_RssSite(t *testing.T) {
|
||||
ts, rst, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
waitOnSecChange()
|
||||
|
||||
pubDate := time.Now().Format(time.RFC1123Z)
|
||||
base, pubDate := rssPubTime()
|
||||
|
||||
c1 := store.Comment{
|
||||
ID: "comment-id-1",
|
||||
Text: "test 123",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
ID: "comment-id-1",
|
||||
Text: "test 123",
|
||||
Timestamp: base,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
}
|
||||
c2 := store.Comment{
|
||||
ID: "comment-id-2",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
ID: "comment-id-2",
|
||||
Text: "xyz test",
|
||||
Timestamp: base.Add(time.Millisecond),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
}
|
||||
|
||||
_, err := rst.DataService.Create(c1)
|
||||
@@ -126,22 +133,22 @@ func TestServer_RssWithReply(t *testing.T) {
|
||||
ts, rst, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
waitOnSecChange()
|
||||
|
||||
pubDate := time.Now().Format(time.RFC1123Z)
|
||||
base, pubDate := rssPubTime()
|
||||
|
||||
c1 := store.Comment{
|
||||
ID: "comment-id-1",
|
||||
Text: "test 123",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
ID: "comment-id-1",
|
||||
Text: "test 123",
|
||||
Timestamp: base,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
}
|
||||
c2 := store.Comment{
|
||||
ID: "comment-id-2",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
ID: "comment-id-2",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Timestamp: base.Add(time.Millisecond),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||
User: store.User{ID: "u1", Name: "developer one"},
|
||||
}
|
||||
|
||||
_, err := rst.DataService.Create(c1)
|
||||
@@ -186,42 +193,45 @@ func TestServer_RssReplies(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
waitOnSecChange()
|
||||
|
||||
pubDate := time.Now().Format(time.RFC1123Z)
|
||||
base, pubDate := rssPubTime()
|
||||
|
||||
c1 := store.Comment{
|
||||
ID: "comment-1",
|
||||
Text: "c1",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "user1", Name: "user1"},
|
||||
ID: "comment-1",
|
||||
Text: "c1",
|
||||
Timestamp: base,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "user1", Name: "user1"},
|
||||
}
|
||||
c2 := store.Comment{
|
||||
ID: "comment-2",
|
||||
Text: "reply to c1 from user2",
|
||||
ParentID: "comment-1",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "user2", Name: "user2"},
|
||||
ID: "comment-2",
|
||||
Text: "reply to c1 from user2",
|
||||
ParentID: "comment-1",
|
||||
Timestamp: base.Add(time.Millisecond),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "user2", Name: "user2"},
|
||||
}
|
||||
c3 := store.Comment{
|
||||
ID: "comment-3",
|
||||
Text: "reply to c1 from user3",
|
||||
ParentID: "comment-1",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "user3", Name: "user3"},
|
||||
ID: "comment-3",
|
||||
Text: "reply to c1 from user3",
|
||||
ParentID: "comment-1",
|
||||
Timestamp: base.Add(2 * time.Millisecond),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "user3", Name: "user3"},
|
||||
}
|
||||
c4 := store.Comment{
|
||||
ID: "comment-4",
|
||||
Text: "reply to c2 from developer one",
|
||||
ParentID: "comment-2",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "dev", Name: "developer one"},
|
||||
ID: "comment-4",
|
||||
Text: "reply to c2 from developer one",
|
||||
ParentID: "comment-2",
|
||||
Timestamp: base.Add(3 * time.Millisecond),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "dev", Name: "developer one"},
|
||||
}
|
||||
c5 := store.Comment{
|
||||
ID: "comment-5",
|
||||
Text: "developer one",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "dev", Name: "developer one"},
|
||||
ID: "comment-5",
|
||||
Text: "developer one",
|
||||
Timestamp: base.Add(4 * time.Millisecond),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||
User: store.User{ID: "dev", Name: "developer one"},
|
||||
}
|
||||
|
||||
_, err := srv.DataService.Create(c1)
|
||||
@@ -270,12 +280,6 @@ func TestServer_RssReplies(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, code)
|
||||
}
|
||||
|
||||
func waitOnSecChange() {
|
||||
for time.Now().Nanosecond() >= 100000000 {
|
||||
time.Sleep(10 * time.Nanosecond)
|
||||
}
|
||||
}
|
||||
|
||||
// clean formatting, i.e. multiple spaces, \t, \n
|
||||
func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) {
|
||||
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
|
||||
|
||||
+37
-18
@@ -2,16 +2,16 @@ package api
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/go-pkgz/routegroup"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
// sslMode defines ssl mode for rest server
|
||||
@@ -40,13 +40,13 @@ type SSLConfig struct {
|
||||
|
||||
// httpToHTTPSRouter creates new router which does redirect from http to https server
|
||||
// with default middlewares. Used in 'static' ssl mode.
|
||||
func (s *Rest) httpToHTTPSRouter() chi.Router {
|
||||
log.Printf("[DEBUG] create https-to-http redirect routes")
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RealIP, R.Recoverer(log.Default()))
|
||||
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second))
|
||||
func (s *Rest) httpToHTTPSRouter() http.Handler {
|
||||
log.Printf("[DEBUG] create http-to-https redirect routes")
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
router.Use(R.Recoverer(log.Default()))
|
||||
router.Use(R.Throttle(1000), R.Timeout(60*time.Second))
|
||||
|
||||
router.Handle("/*", s.redirectHandler())
|
||||
router.Handle("/", s.redirectHandler())
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -54,26 +54,45 @@ func (s *Rest) httpToHTTPSRouter() chi.Router {
|
||||
// with default middlewares. This part is necessary to obtain certificate from LE.
|
||||
// If it receives not a acme challenge it performs redirect to https server.
|
||||
// Used in 'auto' ssl mode.
|
||||
func (s *Rest) httpChallengeRouter(m *autocert.Manager) chi.Router {
|
||||
func (s *Rest) httpChallengeRouter(m *autocert.Manager) http.Handler {
|
||||
log.Printf("[DEBUG] create http-challenge routes")
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.RealIP, R.Recoverer(log.Default()))
|
||||
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second))
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
router.Use(R.Recoverer(log.Default()))
|
||||
router.Use(R.Throttle(1000), R.Timeout(60*time.Second))
|
||||
|
||||
router.Handle("/*", m.HTTPHandler(s.redirectHandler()))
|
||||
router.Handle("/", m.HTTPHandler(s.redirectHandler()))
|
||||
return router
|
||||
}
|
||||
|
||||
func (s *Rest) redirectHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
newURL := s.RemarkURL + r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
newURL += "?" + r.URL.RawQuery
|
||||
newURL, err := s.redirectURL(r)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to build redirect URL, %s", err)
|
||||
http.Error(w, "invalid redirect URL", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, newURL, http.StatusTemporaryRedirect)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Rest) redirectURL(r *http.Request) (string, error) {
|
||||
baseURL, err := url.Parse(s.RemarkURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse remark URL: %w", err)
|
||||
}
|
||||
if baseURL.Scheme != "http" && baseURL.Scheme != "https" || baseURL.Host == "" {
|
||||
return "", fmt.Errorf("remark URL must be absolute HTTP(S) URL")
|
||||
}
|
||||
|
||||
basePath := strings.TrimRight(baseURL.Path, "/")
|
||||
requestPath := "/" + strings.TrimLeft(r.URL.Path, "/")
|
||||
baseURL.Path = basePath + requestPath
|
||||
baseURL.RawQuery = r.URL.RawQuery
|
||||
baseURL.Fragment = ""
|
||||
return baseURL.String(), nil
|
||||
}
|
||||
|
||||
func (s *Rest) makeAutocertManager() *autocert.Manager {
|
||||
return &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
|
||||
@@ -40,6 +40,16 @@ func TestSSL_Redirect(t *testing.T) {
|
||||
assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestSSL_RedirectURLKeepsConfiguredHost(t *testing.T) {
|
||||
rest := Rest{RemarkURL: "https://localhost:443/base"}
|
||||
req, err := http.NewRequest("GET", "http://example.com//evil.test/path?next=//evil.test", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
redirectURL, err := rest.redirectURL(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://localhost:443/base/evil.test/path?next=//evil.test", redirectURL)
|
||||
}
|
||||
|
||||
func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
|
||||
rest := Rest{
|
||||
RemarkURL: "https://localhost:443",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// webFiles serves /web from two sources: a name present in the frontend build is served from there,
|
||||
// and any other name from the assets embedded in the binary.
|
||||
type webFiles struct {
|
||||
frontend fs.FS
|
||||
embedded fs.FS
|
||||
}
|
||||
|
||||
// Open resolves the name against both sources, and answers a missing .js with the .mjs sibling.
|
||||
// The build stopped emitting .js while integrations still request it; the bundles carry no module
|
||||
// syntax, so the same bytes serve both names.
|
||||
func (w webFiles) Open(name string) (fs.File, error) {
|
||||
// fs.ValidPath alone is not enough: it accepts names the operating system rejects, NUL among
|
||||
// them, and os.DirFS turns those into fs.ErrInvalid, which renders as 500 rather than 404
|
||||
if _, err := filepath.Localize(name); err != nil || !fs.ValidPath(name) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
f, err := w.open(name)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) || !strings.HasSuffix(name, ".js") {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
alias, aliasErr := w.open(strings.TrimSuffix(name, ".js") + ".mjs")
|
||||
if aliasErr == nil {
|
||||
return alias, nil
|
||||
}
|
||||
if !errors.Is(aliasErr, fs.ErrNotExist) {
|
||||
return nil, aliasErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// open looks the name up in the frontend build first. Only a missing file falls through to the
|
||||
// embedded assets; every other error is returned so an unreadable file keeps reporting as one
|
||||
// rather than being replaced by the embedded copy or reported as missing.
|
||||
func (w webFiles) open(name string) (fs.File, error) {
|
||||
f, err := w.frontend.Open(name)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
if name == "." {
|
||||
// the embedded set is a flat list of files; only the frontend build answers for the
|
||||
// directory itself, so a missing web root reports as missing rather than listing them
|
||||
return nil, err
|
||||
}
|
||||
return w.embedded.Open(name)
|
||||
}
|
||||
|
||||
// emptyFS stands in for a frontend source that could not be opened, so a misconfigured one serves
|
||||
// nothing instead of panicking or serving the build at paths it does not belong at
|
||||
type emptyFS struct{}
|
||||
|
||||
func (emptyFS) Open(name string) (fs.File, error) {
|
||||
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||
}
|
||||
|
||||
// remarkURLPlaceholder is what the frontend build carries wherever the instance URL belongs. The
|
||||
// bundler cannot know that URL, so it emits this marker and every distribution fills it in: the
|
||||
// docker image rewrites the files under the web root at container start, and the binary, which
|
||||
// serves the build embedded in itself and has nothing to rewrite, does it here.
|
||||
const remarkURLPlaceholder = "{% REMARK_URL %}"
|
||||
|
||||
// templatedFS fills the instance URL into the files carrying the placeholder. Without it the
|
||||
// binary serves whatever the build baked in, which is a host no visitor can reach, and the widget
|
||||
// falls back to it whenever a page omits remark_config.host.
|
||||
type templatedFS struct {
|
||||
fs fs.FS
|
||||
remarkURL string
|
||||
}
|
||||
|
||||
// Open substitutes in the file types the frontend templates, and hands everything else through
|
||||
// untouched so images and stylesheets keep streaming from their original source
|
||||
func (t templatedFS) Open(name string) (fs.File, error) {
|
||||
f, err := t.fs.Open(name)
|
||||
if err != nil || !templatedName(name) {
|
||||
return f, err
|
||||
}
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
return f, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(f)
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body = bytes.ReplaceAll(body, []byte(remarkURLPlaceholder), []byte(t.remarkURL))
|
||||
return &memFile{Reader: bytes.NewReader(body), info: sizedInfo{FileInfo: info, size: int64(len(body))}}, nil
|
||||
}
|
||||
|
||||
// templatedName reports whether the frontend templates this file type. It mirrors the set the
|
||||
// docker image rewrites, so both distributions substitute in the same files
|
||||
func templatedName(name string) bool {
|
||||
switch filepath.Ext(name) {
|
||||
case ".html", ".js", ".mjs":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// memFile is a substituted file held in memory. The file server needs a seeker to answer range
|
||||
// requests and to sniff a content type, which a substituted body no longer has on disk
|
||||
type memFile struct {
|
||||
*bytes.Reader
|
||||
info fs.FileInfo
|
||||
}
|
||||
|
||||
func (f *memFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||
func (f *memFile) Close() error { return nil }
|
||||
|
||||
// sizedInfo reports the length after substitution. The file server writes Content-Length from it,
|
||||
// so reporting the length on disk would truncate the response or leave the client waiting
|
||||
type sizedInfo struct {
|
||||
fs.FileInfo
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i sizedInfo) Size() int64 { return i.size }
|
||||
@@ -0,0 +1,343 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/go-pkgz/routegroup"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/webassets"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWebFiles_Open(t *testing.T) {
|
||||
frontend := fstest.MapFS{
|
||||
"both.html": {Data: []byte("from the frontend build")},
|
||||
"only-frontend.html": {Data: []byte("frontend only")},
|
||||
}
|
||||
embedded := fstest.MapFS{
|
||||
"both.html": {Data: []byte("from the embedded assets")},
|
||||
"only-embedded.html": {Data: []byte("embedded only")},
|
||||
}
|
||||
w := webFiles{frontend: frontend, embedded: embedded}
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
lookup string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "present in both is served from the frontend build", lookup: "both.html", want: "from the frontend build"},
|
||||
{name: "frontend only", lookup: "only-frontend.html", want: "frontend only"},
|
||||
{name: "embedded only", lookup: "only-embedded.html", want: "embedded only"},
|
||||
{name: "missing in both", lookup: "neither.html", wantErr: fs.ErrNotExist},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, err := w.Open(tt.lookup)
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
b, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, string(b))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFiles_OpenJSAlias(t *testing.T) {
|
||||
frontend := fstest.MapFS{
|
||||
"embed.mjs": {Data: []byte("module embed")},
|
||||
"counter.js": {Data: []byte("operator's own counter")},
|
||||
"counter.mjs": {Data: []byte("module counter")},
|
||||
"widget.mjs": {Data: []byte("module widget")},
|
||||
}
|
||||
embedded := fstest.MapFS{
|
||||
"legacy.mjs": {Data: []byte("module legacy")},
|
||||
"widget.js": {Data: []byte("embedded widget")},
|
||||
}
|
||||
w := webFiles{frontend: frontend, embedded: embedded}
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
lookup string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "missing js served from the mjs sibling", lookup: "embed.js", want: "module embed"},
|
||||
{name: "alias reaches the embedded assets too", lookup: "legacy.js", want: "module legacy"},
|
||||
{name: "a real js file wins over its sibling", lookup: "counter.js", want: "operator's own counter"},
|
||||
{name: "an embedded js wins over a frontend sibling", lookup: "widget.js", want: "embedded widget"},
|
||||
{name: "mjs is still served directly", lookup: "embed.mjs", want: "module embed"},
|
||||
{name: "neither name present", lookup: "absent.js", wantErr: fs.ErrNotExist},
|
||||
{name: "only js aliases, not other extensions", lookup: "embed.html", wantErr: fs.ErrNotExist},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, err := w.Open(tt.lookup)
|
||||
if tt.wantErr != nil {
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
b, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, string(b))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFiles_OpenJSAliasNamesTheRequestedFile(t *testing.T) {
|
||||
w := webFiles{frontend: fstest.MapFS{}, embedded: fstest.MapFS{}}
|
||||
|
||||
_, err := w.Open("absent.js")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||
assert.Contains(t, err.Error(), "absent.js")
|
||||
assert.NotContains(t, err.Error(), "absent.mjs")
|
||||
}
|
||||
|
||||
func TestWebFiles_OpenJSAliasUnreadableSibling(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root ignores file permissions")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "embed.mjs"), []byte("module embed"), 0o000))
|
||||
w := webFiles{frontend: os.DirFS(dir), embedded: fstest.MapFS{}}
|
||||
|
||||
f, err := w.Open("embed.js")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, fs.ErrPermission)
|
||||
assert.NotErrorIs(t, err, fs.ErrNotExist, "an unreadable sibling must not render as 404")
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyFS_ServesNothing pins the stand-in used when the frontend source cannot be opened:
|
||||
// every name must report as missing rather than panicking, since it backs a nil-free fallback.
|
||||
func TestEmptyFS_ServesNothing(t *testing.T) {
|
||||
for _, name := range []string{".", "index.html", "web/index.html"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f, err := emptyFS{}.Open(name)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||
assert.Nil(t, f)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFiles_EmptyFrontendFallsThrough covers the shape routes() builds when fs.Sub refuses:
|
||||
// the embedded assets must still answer even though the frontend source serves nothing.
|
||||
func TestWebFiles_EmptyFrontendFallsThrough(t *testing.T) {
|
||||
w := webFiles{frontend: emptyFS{}, embedded: webassets.FS}
|
||||
|
||||
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := w.Open("privacy.html")
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
got, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(want), string(got))
|
||||
}
|
||||
|
||||
// TestWebFiles_OpenRootIsNotListed keeps the embedded assets from being browsable: they answer
|
||||
// for their own names only, so a web root that has gone missing reports as missing.
|
||||
func TestWebFiles_OpenRootIsNotListed(t *testing.T) {
|
||||
w := webFiles{frontend: os.DirFS(filepath.Join(t.TempDir(), "absent")), embedded: webassets.FS}
|
||||
|
||||
f, err := w.Open(".")
|
||||
require.Error(t, err, "the embedded assets must not answer for the directory itself")
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
// the assets themselves still serve
|
||||
f, err = w.Open("privacy.html")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
}
|
||||
|
||||
// TestWebFiles_OpenInvalidName pins that a name fs rejects reports as missing rather than invalid.
|
||||
// os.DirFS returns fs.ErrInvalid for these, which http.FileServer renders as 500, so the check has
|
||||
// to happen before the lookup. A memory filesystem cannot show this: it reports missing either way.
|
||||
func TestWebFiles_OpenInvalidName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "privacy.html"), []byte("frontend"), 0o600))
|
||||
w := webFiles{frontend: os.DirFS(dir), embedded: webassets.FS}
|
||||
|
||||
for _, name := range []string{"../escape.html", "/etc/passwd", "a\x00b.html", "./privacy.html"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
f, err := w.Open(name)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||
assert.NotErrorIs(t, err, fs.ErrInvalid, "an invalid name must not surface as 500")
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebFiles_OpenUnreadableFrontendFile pins the rule that only a missing file falls through:
|
||||
// a frontend file that cannot be read must report that, not be masked by the embedded copy.
|
||||
func TestWebFiles_OpenUnreadableFrontendFile(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root ignores file permissions")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "privacy.html"), []byte("operator's own"), 0o000))
|
||||
|
||||
w := webFiles{
|
||||
frontend: os.DirFS(dir),
|
||||
embedded: fstest.MapFS{"privacy.html": {Data: []byte("built in")}},
|
||||
}
|
||||
|
||||
f, err := w.Open("privacy.html")
|
||||
require.Error(t, err, "an unreadable frontend file must not be replaced by the embedded copy")
|
||||
assert.NotErrorIs(t, err, fs.ErrNotExist, "the error must stay a permission error so it does not render as 404")
|
||||
assert.ErrorIs(t, err, fs.ErrPermission)
|
||||
if err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatedFS_SubstitutesTheInstanceURL(t *testing.T) {
|
||||
const placeholder = "host: '" + remarkURLPlaceholder + "'"
|
||||
source := fstest.MapFS{
|
||||
"iframe.html": {Data: []byte(placeholder)},
|
||||
"embed.mjs": {Data: []byte(placeholder)},
|
||||
"embed.js": {Data: []byte(placeholder)},
|
||||
"remark.css": {Data: []byte(placeholder)},
|
||||
"nothing.html": {Data: []byte("no marker here")},
|
||||
}
|
||||
tfs := templatedFS{fs: source, remarkURL: "https://remark.example.com"}
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
want string
|
||||
}{
|
||||
{"iframe.html", "host: 'https://remark.example.com'"},
|
||||
{"embed.mjs", "host: 'https://remark.example.com'"},
|
||||
{"embed.js", "host: 'https://remark.example.com'"},
|
||||
// the docker image rewrites html, js and mjs and nothing else, and a stylesheet carrying
|
||||
// the marker would mean the frontend started templating a file type this does not cover
|
||||
{"remark.css", placeholder},
|
||||
{"nothing.html", "no marker here"},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, err := tfs.Open(tt.name)
|
||||
require.NoError(t, err)
|
||||
defer func() { assert.NoError(t, f.Close()) }()
|
||||
|
||||
body, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, string(body))
|
||||
|
||||
info, err := f.Stat()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(len(tt.want)), info.Size(),
|
||||
"the size has to be the substituted one, or the response is truncated or left hanging")
|
||||
assert.Equal(t, tt.name, info.Name())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplatedFS_PassesErrorsThrough(t *testing.T) {
|
||||
tfs := templatedFS{fs: fstest.MapFS{}, remarkURL: "https://remark.example.com"}
|
||||
|
||||
_, err := tfs.Open("absent.html")
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
// TestRest_FileServerFillsInTheInstanceURL covers the reason templatedFS exists: the binary serves
|
||||
// the frontend build embedded in itself, and nothing else fills the placeholder in for it.
|
||||
func TestRest_FileServerFillsInTheInstanceURL(t *testing.T) {
|
||||
frontend := fstest.MapFS{
|
||||
"embed.mjs": {Data: []byte("host=\"" + remarkURLPlaceholder + "\"")},
|
||||
"logo.svg": {Data: []byte(remarkURLPlaceholder)},
|
||||
"plain.html": {Data: []byte("nothing to fill in")},
|
||||
}
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com")
|
||||
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("the bundle carries the configured url", func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+"/web/embed.mjs")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, `host="https://remark.example.com"`, body)
|
||||
})
|
||||
|
||||
t.Run("the legacy js name carries it too", func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+"/web/embed.js")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, `host="https://remark.example.com"`, body)
|
||||
})
|
||||
|
||||
t.Run("other types are served untouched", func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+"/web/logo.svg")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, remarkURLPlaceholder, body)
|
||||
})
|
||||
|
||||
t.Run("a file without the marker is unchanged", func(t *testing.T) {
|
||||
body, code := get(t, ts.URL+"/web/plain.html")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, "nothing to fill in", body)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRest_FileServerEtagVariesWithTheInstanceURL covers the case this substitution exists for. An
|
||||
// operator who notices the widget is addressed to the wrong host corrects REMARK_URL and restarts,
|
||||
// and the binary and so the version is unchanged. If the validator ignores remarkURL the client
|
||||
// revalidates, gets 304 and keeps the bundle pointing at the old host. Cache-Control is no-cache,
|
||||
// so it revalidates every time and never ages out of that state.
|
||||
func TestRest_FileServerEtagVariesWithTheInstanceURL(t *testing.T) {
|
||||
frontend := fstest.MapFS{"embed.mjs": {Data: []byte("host=\"" + remarkURLPlaceholder + "\"")}}
|
||||
|
||||
etagFor := func(remarkURL string) string {
|
||||
router := routegroup.New(http.NewServeMux())
|
||||
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", remarkURL)
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/web/embed.mjs")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
return resp.Header.Get("Etag")
|
||||
}
|
||||
|
||||
first := etagFor("https://old.example.com")
|
||||
second := etagFor("https://new.example.com")
|
||||
|
||||
require.NotEmpty(t, first, "the file server has to send a validator at all")
|
||||
assert.NotEqual(t, first, second,
|
||||
"same version and same path, different instance url: the validator has to change or the "+
|
||||
"client keeps a bundle addressed to the old host")
|
||||
}
|
||||
@@ -813,8 +813,8 @@ func imgHTTPTestsServer(t *testing.T) *httptest.Server {
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/image/img-slow.png" {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
w.WriteHeader(500)
|
||||
// hold the response until the proxy gives up on its own timeout
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
t.Log("http img request - not found", r.URL)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -151,8 +152,8 @@ func (c *Comment) Snippet(limit int) string {
|
||||
}
|
||||
snippet := []rune(cleanText)[:limit]
|
||||
// go back in snippet and found the first space
|
||||
for i := len(snippet) - 1; i >= 0; i-- {
|
||||
if snippet[i] == ' ' {
|
||||
for i, s := range slices.Backward(snippet) {
|
||||
if s == ' ' {
|
||||
snippet = snippet[:i]
|
||||
break
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package engine
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
berrors "go.etcd.io/bbolt/errors"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
@@ -413,14 +414,14 @@ func (b *BoltDB) Delete(req DeleteRequest) error {
|
||||
|
||||
// Close boltdb store
|
||||
func (b *BoltDB) Close() error {
|
||||
errs := new(multierror.Error)
|
||||
var errs []error
|
||||
for site, db := range b.dbs {
|
||||
err := db.Close()
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf("can't close site %s: %w", site, err))
|
||||
errs = append(errs, fmt.Errorf("can't close site %s: %w", site, err))
|
||||
}
|
||||
}
|
||||
return errs.ErrorOrNil()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Last returns up to max last comments for given siteID
|
||||
@@ -893,31 +894,26 @@ func (b *BoltDB) deleteUser(bdb *bolt.DB, siteID, userID string, mode store.Dele
|
||||
// delete collected comments
|
||||
for _, ci := range comments {
|
||||
if e := b.deleteComment(bdb, ci.locator, ci.commentID, mode); e != nil {
|
||||
return fmt.Errorf("failed to delete comment %+v: %w", ci, err)
|
||||
return fmt.Errorf("failed to delete comment %+v: %w", ci, e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete user bucket in hard mode
|
||||
// delete the user's bucket in hard mode. A user who only logged in but never commented has
|
||||
// no per-user bucket, so tolerate ErrBucketNotFound; the top-level users bucket is created
|
||||
// by NewBoltDB and is always present.
|
||||
if mode == store.HardDelete {
|
||||
err = bdb.Update(func(tx *bolt.Tx) error {
|
||||
usersBkt := tx.Bucket([]byte(userBucketName))
|
||||
if usersBkt != nil {
|
||||
if e := usersBkt.DeleteBucket([]byte(userID)); e != nil {
|
||||
return fmt.Errorf("failed to delete user bucket for %s: %w", userID, err)
|
||||
}
|
||||
if e := usersBkt.DeleteBucket([]byte(userID)); e != nil && !errors.Is(e, berrors.ErrBucketNotFound) {
|
||||
return fmt.Errorf("failed to delete user bucket for %s: %w", userID, e)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't delete user meta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(comments) == 0 {
|
||||
return fmt.Errorf("unknown user %s", userID)
|
||||
}
|
||||
|
||||
return b.deleteUserDetail(bdb, userID, AllUserDetails)
|
||||
}
|
||||
|
||||
@@ -1022,7 +1018,7 @@ func (b *BoltDB) db(siteID string) (*bolt.DB, error) {
|
||||
if res, ok := b.dbs[siteID]; ok {
|
||||
return res, nil
|
||||
}
|
||||
return nil, fmt.Errorf("site %q not found", siteID)
|
||||
return nil, fmt.Errorf("site %q %w", siteID, ErrSiteNotFound)
|
||||
}
|
||||
|
||||
// makeRef creates reference combining url and comment id
|
||||
|
||||
@@ -843,6 +843,63 @@ func TestBoltAdmin_DeleteUserHard(t *testing.T) {
|
||||
assert.EqualError(t, err, `site "radio-t-bad" not found`)
|
||||
}
|
||||
|
||||
// TestBoltAdmin_DeleteUserHard_NoComments covers hard-deleting a user who has no comments
|
||||
// (and therefore no user bucket) — e.g. one who only logged in. This must succeed rather than
|
||||
// fail on the missing bucket, and must still remove any stored user details.
|
||||
func TestBoltAdmin_DeleteUserHard_NoComments(t *testing.T) {
|
||||
b, teardown := prep(t)
|
||||
defer teardown()
|
||||
|
||||
t.Run("login-only user with a detail but no comments", func(t *testing.T) {
|
||||
const userID = "login-only-user"
|
||||
loc := store.Locator{SiteID: "radio-t"}
|
||||
|
||||
// user logged in and has a stored detail, but never commented (no user bucket)
|
||||
_, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail, Update: "user@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = b.Delete(DeleteRequest{Locator: loc, UserID: userID, DeleteMode: store.HardDelete})
|
||||
require.NoError(t, err, "hard delete must not fail on a missing user bucket")
|
||||
|
||||
details, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, details, "stored user detail must be removed on hard delete")
|
||||
})
|
||||
|
||||
t.Run("unknown user is a no-op", func(t *testing.T) {
|
||||
err := b.Delete(DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "never-seen-user", DeleteMode: store.HardDelete})
|
||||
assert.NoError(t, err, "hard-deleting an unknown user must not error")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBoltAdmin_DeleteUserSoft_NoComments covers soft-deleting a user with no comments. As with the
|
||||
// hard path (and the existing soft path for users who do have comments) it cleans stored user
|
||||
// details and is a no-op for an unknown user.
|
||||
func TestBoltAdmin_DeleteUserSoft_NoComments(t *testing.T) {
|
||||
b, teardown := prep(t)
|
||||
defer teardown()
|
||||
|
||||
t.Run("login-only user with a detail but no comments", func(t *testing.T) {
|
||||
const userID = "login-only-soft"
|
||||
loc := store.Locator{SiteID: "radio-t"}
|
||||
|
||||
_, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail, Update: "user@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = b.Delete(DeleteRequest{Locator: loc, UserID: userID, DeleteMode: store.SoftDelete})
|
||||
require.NoError(t, err)
|
||||
|
||||
details, err := b.UserDetail(UserDetailRequest{Locator: loc, UserID: userID, Detail: UserEmail})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, details, "soft delete cleans stored user details, consistent with the has-comments path")
|
||||
})
|
||||
|
||||
t.Run("unknown user is a no-op", func(t *testing.T) {
|
||||
err := b.Delete(DeleteRequest{Locator: store.Locator{SiteID: "radio-t"}, UserID: "never-seen-soft", DeleteMode: store.SoftDelete})
|
||||
assert.NoError(t, err, "soft-deleting an unknown user must not error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoltAdmin_DeleteUserSoft(t *testing.T) {
|
||||
b, teardown := prep(t)
|
||||
defer teardown()
|
||||
|
||||
@@ -4,6 +4,7 @@ package engine
|
||||
// Includes default implementation with boltdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// ErrSiteNotFound is returned by engines when the requested site does not exist.
|
||||
// Its message is "not found" so wrapping it as `site %q %w` reads "site \"x\" not found".
|
||||
var ErrSiteNotFound = errors.New("not found")
|
||||
|
||||
// NOTE: matryer/moq should be installed globally and works with `go generate ./...`
|
||||
//go:generate moq --out engine_mock.go . Interface
|
||||
|
||||
|
||||
@@ -147,8 +147,8 @@ func (f *FileSystem) Cleanup(_ context.Context, ttl time.Duration) error {
|
||||
age := time.Since(info.ModTime())
|
||||
if age > (ttl + 100*time.Millisecond) { // delay cleanup triggering to allow commit
|
||||
log.Printf("[INFO] remove staging image %s, age %v", fpath, age)
|
||||
rmErr := os.Remove(fpath) //nolint:gosec // staging dir is server-only, no untrusted symlinks land here
|
||||
_ = os.Remove(path.Dir(fpath)) //nolint:gosec // same staging dir
|
||||
rmErr := os.Remove(fpath) //nolint:gosec // staging dir is server-only, no untrusted symlinks land here
|
||||
_ = os.Remove(path.Dir(fpath)) //nolint:gosec // same staging dir
|
||||
return rmErr
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -215,15 +215,24 @@ func TestFsStore_Cleanup(t *testing.T) {
|
||||
return img
|
||||
}
|
||||
|
||||
// age is read from the file's modification time, so every file gets its mtime stamped right
|
||||
// before each call: far past the ttl for the ones meant to go, at now for the ones meant to
|
||||
// survive, leaving no window for a stalled runner to age a survivor into the wrong bucket
|
||||
const ttl = 300 * time.Millisecond
|
||||
age := func(file string, d time.Duration) {
|
||||
mtime := time.Now().Add(-d)
|
||||
require.NoError(t, os.Chtimes(file, mtime, mtime))
|
||||
}
|
||||
|
||||
// save 3 images to staging
|
||||
img1 := save("blah_ff1.png", "user1")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
img2 := save("blah_ff2.png", "user1")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
img3 := save("blah_ff3.png", "user2")
|
||||
|
||||
time.Sleep(200 * time.Millisecond) // make first image expired
|
||||
err := svc.Cleanup(context.Background(), time.Millisecond*300)
|
||||
age(img1, time.Hour) // past the ttl, collected
|
||||
age(img2, 0) // fresh, survives
|
||||
age(img3, 0)
|
||||
err := svc.Cleanup(context.Background(), ttl)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(img1)
|
||||
@@ -242,10 +251,11 @@ func TestFsStore_Cleanup(t *testing.T) {
|
||||
_, err = os.Stat(img3)
|
||||
assert.NoError(t, err, "file on staging")
|
||||
|
||||
time.Sleep(200 * time.Millisecond) // make all images expired
|
||||
age(img2, time.Hour)
|
||||
age(img3, time.Hour)
|
||||
err = svc.ResetCleanupTimer("user2/blah_ff3.png") // reset the time to cleanup for third image
|
||||
assert.NoError(t, err)
|
||||
err = svc.Cleanup(context.Background(), time.Millisecond*300)
|
||||
err = svc.Cleanup(context.Background(), ttl)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = os.Stat(img2)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha1" //nolint:gosec // not used for cryptography
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif" // register gif decoder
|
||||
@@ -27,7 +28,6 @@ import (
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/rs/xid"
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // register webp decoder so DecodeConfig accepts what readAndValidateImage allows
|
||||
@@ -43,8 +43,8 @@ type Service struct {
|
||||
wg sync.WaitGroup
|
||||
submitCh chan submitReq
|
||||
once sync.Once
|
||||
term int32 // term value used atomically to detect emergency termination
|
||||
submitCount int32 // atomic increment for counting submitted images
|
||||
term atomic.Int32 // term value used atomically to detect emergency termination
|
||||
submitCount atomic.Int32 // atomic increment for counting submitted images
|
||||
}
|
||||
|
||||
// ServiceParams contains externally adjustable parameters of Service
|
||||
@@ -91,14 +91,14 @@ func NewService(s Store, p ServiceParams) *Service {
|
||||
|
||||
// Commit multiple ids immediately
|
||||
func (s *Service) Commit(idsFn func() []string) error {
|
||||
errs := new(multierror.Error)
|
||||
var errs []error
|
||||
for _, id := range idsFn() {
|
||||
err := s.store.Commit(id)
|
||||
if err != nil {
|
||||
errs = multierror.Append(errs, fmt.Errorf("failed to commit image %s: %w", id, err))
|
||||
errs = append(errs, fmt.Errorf("failed to commit image %s: %w", id, err))
|
||||
}
|
||||
}
|
||||
return errs.ErrorOrNil()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Submit multiple ids via function for delayed commit
|
||||
@@ -113,7 +113,7 @@ func (s *Service) Submit(idsFn func() []string) {
|
||||
s.wg.Go(func() {
|
||||
for req := range s.submitCh {
|
||||
// wait for EditDuration expiration with emergency pass on term
|
||||
for atomic.LoadInt32(&s.term) == 0 && time.Since(req.TS) <= s.EditDuration {
|
||||
for s.term.Load() == 0 && time.Since(req.TS) <= s.EditDuration {
|
||||
time.Sleep(time.Millisecond * 10) // small sleep to relive busy wait but keep reactive for term (close)
|
||||
}
|
||||
err := s.Commit(req.idsFn)
|
||||
@@ -121,13 +121,13 @@ func (s *Service) Submit(idsFn func() []string) {
|
||||
log.Printf("[WARN] image commit error %v", err)
|
||||
}
|
||||
|
||||
atomic.AddInt32(&s.submitCount, -1)
|
||||
s.submitCount.Add(-1)
|
||||
}
|
||||
log.Printf("[INFO] image submitter terminated")
|
||||
})
|
||||
})
|
||||
|
||||
atomic.AddInt32(&s.submitCount, 1)
|
||||
s.submitCount.Add(1)
|
||||
|
||||
// reset cleanup timer before submitting the images
|
||||
// to prevent them from being cleaned up while waiting for EditDuration to expire
|
||||
@@ -196,14 +196,14 @@ func (s *Service) Close(ctx context.Context) {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if atomic.LoadInt32(&s.submitCount) == 0 {
|
||||
if s.submitCount.Load() == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
atomic.StoreInt32(&s.term, 1) // enforce non-delayed commits for all ids left in submitCh
|
||||
s.term.Store(1) // enforce non-delayed commits for all ids left in submitCh
|
||||
waitForTerm(ctx)
|
||||
|
||||
if s.submitCh != nil {
|
||||
|
||||
@@ -41,13 +41,15 @@ func TestService_SaveAndLoad(t *testing.T) {
|
||||
assert.Equal(t, "test_id", store.LoadCalls()[0].ID)
|
||||
}
|
||||
|
||||
// the resized dimensions are what resize promises; the encoded length is whatever the compressor
|
||||
// in the toolchain happens to produce, and pinning it fails on a go release that changes it
|
||||
func TestService_Resize(t *testing.T) {
|
||||
img, err := readAndValidateImage(gopherPNG(), 1500)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1462, len(img))
|
||||
assert.NotEmpty(t, img)
|
||||
|
||||
img = resize(img, 32, 32)
|
||||
assert.Equal(t, 1135, len(img))
|
||||
assertImageFits(t, img, 32, 32)
|
||||
}
|
||||
|
||||
func TestService_ResizeJpeg(t *testing.T) {
|
||||
@@ -57,10 +59,24 @@ func TestService_ResizeJpeg(t *testing.T) {
|
||||
|
||||
img, err := readAndValidateImage(fh, 32000)
|
||||
assert.NoError(t, err)
|
||||
assert.InDelta(t, 16756, len(img), 100)
|
||||
assert.NotEmpty(t, img)
|
||||
|
||||
img = resize(img, 400, 300)
|
||||
assert.InDelta(t, 10913, len(img), 100)
|
||||
assertImageFits(t, img, 400, 300)
|
||||
}
|
||||
|
||||
// assertImageFits decodes the image and checks it is inside the box resize was given, and that it
|
||||
// touches one side of it, which is what fitting to a box rather than merely shrinking means
|
||||
func assertImageFits(t *testing.T, data []byte, limitW, limitH int) {
|
||||
t.Helper()
|
||||
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
require.NoError(t, err, "the resized image does not decode")
|
||||
|
||||
assert.LessOrEqual(t, cfg.Width, limitW, "wider than the box it was resized into")
|
||||
assert.LessOrEqual(t, cfg.Height, limitH, "taller than the box it was resized into")
|
||||
assert.True(t, cfg.Width == limitW || cfg.Height == limitH,
|
||||
"%dx%d touches neither side of the %dx%d box, so it was not fitted to it", cfg.Width, cfg.Height, limitW, limitH)
|
||||
}
|
||||
|
||||
func TestService_SaveTooLarge(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
"github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
bf "github.com/russross/blackfriday/v2"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
@@ -128,6 +128,7 @@ func (s *DataStore) FindSince(locator store.Locator, sortMethod string, user sto
|
||||
}
|
||||
|
||||
changedSort := false
|
||||
flags := s.newUserFlagCache()
|
||||
// sets votes controversy for comments added prior to #274
|
||||
// also sanitizes locator.URL for comments added prior to #927
|
||||
for i, c := range comments {
|
||||
@@ -137,7 +138,7 @@ func (s *DataStore) FindSince(locator store.Locator, sortMethod string, user sto
|
||||
changedSort = true
|
||||
}
|
||||
}
|
||||
comments[i] = s.alterComment(c, user)
|
||||
comments[i] = s.alterCommentCached(c, user, flags)
|
||||
}
|
||||
|
||||
// resort commits if altered
|
||||
@@ -249,18 +250,18 @@ func (s *DataStore) ResubmitStagingImages(sites []string) error {
|
||||
if ts.IsZero() {
|
||||
return nil
|
||||
}
|
||||
result := new(multierror.Error)
|
||||
var errs []error
|
||||
for _, site := range sites {
|
||||
locator := store.Locator{SiteID: site}
|
||||
comments, err := s.FindSince(locator, "time", store.User{}, ts)
|
||||
if err != nil {
|
||||
result = multierror.Append(result, fmt.Errorf("problem finding comments for site %s: %w", site, err))
|
||||
errs = append(errs, fmt.Errorf("problem finding comments for site %s: %w", site, err))
|
||||
}
|
||||
for _, c := range comments {
|
||||
s.submitImages(c)
|
||||
}
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// submitImages initiated delayed commit of all images from the comment uploaded to remark42
|
||||
@@ -915,32 +916,32 @@ func (s *DataStore) Metas(siteID string) (umetas []UserMetaData, pmetas []PostMe
|
||||
|
||||
// SetMetas saves metadata for users and posts
|
||||
func (s *DataStore) SetMetas(siteID string, umetas []UserMetaData, pmetas []PostMetaData) (err error) {
|
||||
errs := new(multierror.Error)
|
||||
var errs []error
|
||||
|
||||
// save posts metas
|
||||
for _, pm := range pmetas {
|
||||
if pm.ReadOnly {
|
||||
errs = multierror.Append(errs, s.SetReadOnly(store.Locator{SiteID: siteID, URL: pm.URL}, true))
|
||||
errs = append(errs, s.SetReadOnly(store.Locator{SiteID: siteID, URL: pm.URL}, true))
|
||||
}
|
||||
}
|
||||
|
||||
// save users metas
|
||||
for _, um := range umetas {
|
||||
if um.Blocked.Status {
|
||||
errs = multierror.Append(errs, s.SetBlock(siteID, um.ID, true, time.Until(um.Blocked.Until)))
|
||||
errs = append(errs, s.SetBlock(siteID, um.ID, true, time.Until(um.Blocked.Until)))
|
||||
}
|
||||
if um.Verified {
|
||||
errs = multierror.Append(errs, s.SetVerified(siteID, um.ID, true))
|
||||
errs = append(errs, s.SetVerified(siteID, um.ID, true))
|
||||
}
|
||||
// this code doesn't delete user details in case they are not set in import but present in DB already
|
||||
if um.Details.Email != "" {
|
||||
req := engine.UserDetailRequest{Locator: store.Locator{SiteID: siteID}, UserID: um.ID, Detail: engine.UserEmail, Update: um.Details.Email}
|
||||
_, err := s.Engine.UserDetail(req)
|
||||
errs = multierror.Append(errs, err)
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs.ErrorOrNil()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// User gets comment for given userID on siteID
|
||||
@@ -972,15 +973,15 @@ func (s *DataStore) Last(siteID string, limit int, since time.Time, user store.U
|
||||
|
||||
// Close store service
|
||||
func (s *DataStore) Close() error {
|
||||
errs := new(multierror.Error)
|
||||
var errs []error
|
||||
if s.repliesCache.LoadingCache != nil {
|
||||
errs = multierror.Append(errs, s.repliesCache.Close())
|
||||
errs = append(errs, s.repliesCache.Close())
|
||||
}
|
||||
if s.TitleExtractor != nil {
|
||||
errs = multierror.Append(errs, s.TitleExtractor.Close())
|
||||
errs = append(errs, s.TitleExtractor.Close())
|
||||
}
|
||||
errs = multierror.Append(errs, s.Engine.Close())
|
||||
return errs.ErrorOrNil()
|
||||
errs = append(errs, s.Engine.Close())
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *DataStore) upsAndDowns(c store.Comment) (ups, downs int) {
|
||||
@@ -1011,25 +1012,28 @@ func (s *DataStore) getScopedLocks(id string) (lock sync.Locker) {
|
||||
|
||||
func (s *DataStore) alterComments(cc []store.Comment, user store.User) (res []store.Comment) {
|
||||
res = make([]store.Comment, len(cc))
|
||||
flags := s.newUserFlagCache()
|
||||
for i, c := range cc {
|
||||
res[i] = s.alterComment(c, user)
|
||||
res[i] = s.alterCommentCached(c, user, flags)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *DataStore) alterComment(c store.Comment, user store.User) (res store.Comment) {
|
||||
blocReq := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: c.Locator.SiteID}, UserID: c.User.ID}
|
||||
blocked, bErr := s.Engine.Flag(blocReq)
|
||||
return s.alterCommentCached(c, user, s.newUserFlagCache())
|
||||
}
|
||||
|
||||
// alterCommentCached is alterComment sharing a userFlagCache so that block/verified
|
||||
// lookups for a user repeated across a listing hit the engine only once.
|
||||
func (s *DataStore) alterCommentCached(c store.Comment, user store.User, flags *userFlagCache) (res store.Comment) {
|
||||
// mark user blocked
|
||||
if bErr == nil && blocked {
|
||||
c.User.Blocked = blocked
|
||||
if flags.blocked(c.Locator.SiteID, c.User.ID) {
|
||||
c.User.Blocked = true
|
||||
}
|
||||
|
||||
// set verified status retroactively
|
||||
if !c.User.Blocked {
|
||||
verifReq := engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: c.Locator.SiteID}, UserID: c.User.ID}
|
||||
c.User.Verified, _ = s.Engine.Flag(verifReq)
|
||||
c.User.Verified = flags.verified(c.Locator.SiteID, c.User.ID)
|
||||
}
|
||||
|
||||
// hide info from non-admins
|
||||
@@ -1043,6 +1047,51 @@ func (s *DataStore) alterComment(c store.Comment, user store.User) (res store.Co
|
||||
return c
|
||||
}
|
||||
|
||||
// userFlagCache memoises engine block/verified flag lookups by site and user within
|
||||
// a single listing, avoiding two engine.Flag calls per comment for repeated users.
|
||||
type userFlagCache struct {
|
||||
s *DataStore
|
||||
blockedM map[flagKey]bool
|
||||
verifiedM map[flagKey]bool
|
||||
}
|
||||
|
||||
type flagKey struct {
|
||||
siteID string
|
||||
userID string
|
||||
}
|
||||
|
||||
func (s *DataStore) newUserFlagCache() *userFlagCache {
|
||||
return &userFlagCache{s: s, blockedM: map[flagKey]bool{}, verifiedM: map[flagKey]bool{}}
|
||||
}
|
||||
|
||||
func (f *userFlagCache) blocked(siteID, userID string) bool {
|
||||
key := flagKey{siteID: siteID, userID: userID}
|
||||
if v, ok := f.blockedM[key]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := f.s.Engine.Flag(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: siteID}, UserID: userID})
|
||||
if err != nil {
|
||||
// don't cache on error so a repeated user is retried, matching the
|
||||
// pre-refactor per-comment behavior; treat this comment as not blocked
|
||||
return false
|
||||
}
|
||||
f.blockedM[key] = v
|
||||
return v
|
||||
}
|
||||
|
||||
func (f *userFlagCache) verified(siteID, userID string) bool {
|
||||
key := flagKey{siteID: siteID, userID: userID}
|
||||
if v, ok := f.verifiedM[key]; ok {
|
||||
return v
|
||||
}
|
||||
v, err := f.s.Engine.Flag(engine.FlagRequest{Flag: engine.Verified, Locator: store.Locator{SiteID: siteID}, UserID: userID})
|
||||
if err != nil {
|
||||
return false // don't cache on error, retry on the next comment for this user
|
||||
}
|
||||
f.verifiedM[key] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// prepare vote info for client view
|
||||
func (s *DataStore) prepVotes(c store.Comment, user store.User) store.Comment {
|
||||
c.Vote = 0 // default is "none" (not voted)
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestService_CreateFromEmpty(t *testing.T) {
|
||||
User: store.User{IP: "192.168.1.1", ID: "user", Name: "name"},
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
}
|
||||
beforeCreate := time.Now()
|
||||
id, err := b.Create(comment)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, id != "", id)
|
||||
@@ -46,7 +47,7 @@ func TestService_CreateFromEmpty(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
t.Logf("%+v", res)
|
||||
assert.Equal(t, "text", res.Text)
|
||||
assert.True(t, time.Since(res.Timestamp).Seconds() < 1)
|
||||
assert.WithinRange(t, res.Timestamp, beforeCreate, time.Now(), "timestamp set during create")
|
||||
assert.Equal(t, "user", res.User.ID)
|
||||
assert.Equal(t, "name", res.User.Name)
|
||||
assert.Equal(t, "23f97cf4d5c29ef788ca2bdd1c9e75656c0e4149", res.User.IP)
|
||||
@@ -218,9 +219,9 @@ func TestService_Put(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_SetTitle(t *testing.T) {
|
||||
var titleEnable int32
|
||||
var titleEnable atomic.Int32
|
||||
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.LoadInt32(&titleEnable) == 0 {
|
||||
if titleEnable.Load() == 0 {
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
if r.URL.String() == "/post1" {
|
||||
@@ -262,7 +263,7 @@ func TestService_SetTitle(t *testing.T) {
|
||||
|
||||
b.TitleExtractor.cache.Purge()
|
||||
|
||||
atomic.StoreInt32(&titleEnable, 1)
|
||||
titleEnable.Store(1)
|
||||
c, err := b.SetTitle(store.Locator{URL: tss.URL + "/post1", SiteID: "radio-t"}, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "post1 blah 123", c.PostTitle)
|
||||
@@ -1878,6 +1879,78 @@ func TestService_alterComment(t *testing.T) {
|
||||
assert.Equal(t, engine.FlagRequest{Flag: engine.Blocked, UserID: "devid"}, engineMock.FlagCalls()[0].Req)
|
||||
}
|
||||
|
||||
func TestService_alterCommentsFlagCaching(t *testing.T) {
|
||||
t.Run("repeated user looked up once", func(t *testing.T) {
|
||||
engineMock := engine.InterfaceMock{
|
||||
FlagFunc: func(engine.FlagRequest) (bool, error) { return false, nil },
|
||||
}
|
||||
svc := DataStore{Engine: &engineMock}
|
||||
|
||||
comments := make([]store.Comment, 0, 5)
|
||||
for i := range 5 {
|
||||
comments = append(comments, store.Comment{ID: fmt.Sprintf("c%d", i),
|
||||
User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}})
|
||||
}
|
||||
svc.alterComments(comments, store.User{ID: "u1"})
|
||||
|
||||
// one Blocked + one Verified lookup for the single user, not two per comment
|
||||
assert.Equal(t, 2, len(engineMock.FlagCalls()), "5 comments by one user -> 2 flag lookups")
|
||||
})
|
||||
|
||||
t.Run("distinct users looked up per user", func(t *testing.T) {
|
||||
engineMock := engine.InterfaceMock{
|
||||
FlagFunc: func(req engine.FlagRequest) (bool, error) {
|
||||
return req.Flag == engine.Blocked && req.UserID == "blocked", nil // "blocked" user is blocked
|
||||
},
|
||||
}
|
||||
svc := DataStore{Engine: &engineMock}
|
||||
|
||||
comments := []store.Comment{
|
||||
{ID: "c1", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
{ID: "c2", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
{ID: "c3", User: store.User{ID: "blocked"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
{ID: "c4", User: store.User{ID: "blocked"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
}
|
||||
res := svc.alterComments(comments, store.User{ID: "admin", Admin: true})
|
||||
|
||||
// u1: Blocked+Verified (2); blocked user: Blocked only, Verified skipped (1) = 3 total
|
||||
assert.Equal(t, 3, len(engineMock.FlagCalls()), "two distinct users -> 3 flag lookups")
|
||||
assert.True(t, res[2].User.Blocked && res[3].User.Blocked, "blocked user marked blocked")
|
||||
assert.False(t, res[0].User.Blocked, "u1 not blocked")
|
||||
})
|
||||
|
||||
t.Run("flag read error is not cached", func(t *testing.T) {
|
||||
var blockedCalls int
|
||||
engineMock := engine.InterfaceMock{
|
||||
FlagFunc: func(req engine.FlagRequest) (bool, error) {
|
||||
if req.Flag == engine.Blocked {
|
||||
blockedCalls++
|
||||
if blockedCalls == 1 {
|
||||
return false, fmt.Errorf("transient flag read error")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
svc := DataStore{Engine: &engineMock}
|
||||
|
||||
comments := []store.Comment{
|
||||
{ID: "c0", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
{ID: "c1", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
{ID: "c2", User: store.User{ID: "u1"}, Locator: store.Locator{SiteID: "site1"}},
|
||||
}
|
||||
res := svc.alterComments(comments, store.User{ID: "admin", Admin: true})
|
||||
|
||||
// the errored first lookup must not be cached, so the next comment retries and
|
||||
// picks up the real blocked state; once it succeeds the result is cached
|
||||
assert.False(t, res[0].User.Blocked, "errored lookup treated as not blocked")
|
||||
assert.True(t, res[1].User.Blocked, "retry after error picks up blocked state")
|
||||
assert.True(t, res[2].User.Blocked, "successful read is cached")
|
||||
assert.Equal(t, 2, blockedCalls, "blocked retried once after the error, then cached")
|
||||
})
|
||||
}
|
||||
|
||||
func Benchmark_ServiceCreate(b *testing.B) {
|
||||
dbFile := fmt.Sprintf("%s/test-remark42-%d.db", os.TempDir(), rand.Intn(9999999999))
|
||||
defer func() { _ = os.Remove(dbFile) }()
|
||||
|
||||
@@ -44,10 +44,10 @@ func TestTitle_GetTitle(t *testing.T) {
|
||||
func TestTitle_Get(t *testing.T) {
|
||||
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
|
||||
defer ex.Close()
|
||||
var hits int32
|
||||
var hits atomic.Int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.String() == "/good" {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
hits.Add(1)
|
||||
_, err := w.Write([]byte("<html><title>\n\n blah 123\n</title><body> 2222</body></html>"))
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
@@ -68,7 +68,7 @@ func TestTitle_Get(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "blah 123", r)
|
||||
}
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&hits))
|
||||
assert.Equal(t, int32(1), hits.Load())
|
||||
}
|
||||
|
||||
func TestTitle_GetConcurrent(t *testing.T) {
|
||||
@@ -78,10 +78,10 @@ func TestTitle_GetConcurrent(t *testing.T) {
|
||||
}
|
||||
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
|
||||
defer ex.Close()
|
||||
var hits int32
|
||||
var hits atomic.Int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.String(), "/good") {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
hits.Add(1)
|
||||
_, err := fmt.Fprintf(w, "<html><title>blah 123 %s</title><body>%s</body></html>", r.URL.String(), body.String())
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
@@ -100,15 +100,15 @@ func TestTitle_GetConcurrent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
g.Wait()
|
||||
assert.Equal(t, int32(100), atomic.LoadInt32(&hits))
|
||||
assert.Equal(t, int32(100), hits.Load())
|
||||
}
|
||||
|
||||
func TestTitle_GetFailed(t *testing.T) {
|
||||
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
|
||||
defer ex.Close()
|
||||
var hits int32
|
||||
var hits atomic.Int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
hits.Add(1)
|
||||
w.WriteHeader(404)
|
||||
}))
|
||||
defer ts.Close()
|
||||
@@ -121,7 +121,7 @@ func TestTitle_GetFailed(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", r)
|
||||
}
|
||||
assert.Equal(t, int32(1), atomic.LoadInt32(&hits), "hit once, errors cached")
|
||||
assert.Equal(t, int32(1), hits.Load(), "hit once, errors cached")
|
||||
}
|
||||
|
||||
func TestTitle_DoubleClosed(t *testing.T) {
|
||||
|
||||
@@ -211,9 +211,9 @@ func (t *Tree) limit(limit int, offsetID string) {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if we just exceeded the limit and there are already some nodes in the list,
|
||||
// as otherwise we would have to return the first node with all its replies even if it exceeds the limit.
|
||||
if commentsCount+repliesCount >= limit && len(limitedNodes) > 0 {
|
||||
// stop once adding this subtree would exceed the limit, as long as we already have a node;
|
||||
// a subtree that fits exactly is still included, and the first node is always returned in full.
|
||||
if commentsCount+repliesCount > limit && len(limitedNodes) > 0 {
|
||||
t.countLeft += repliesCount
|
||||
commentsCount = limit // adjust commentsCount to stop checking limit for the next nodes
|
||||
continue
|
||||
|
||||
@@ -152,6 +152,100 @@ func TestTreeSortNodes(t *testing.T) {
|
||||
assert.Equal(t, "1", res.Nodes[0].Comment.ID)
|
||||
}
|
||||
|
||||
func TestMakeTreeLimit(t *testing.T) {
|
||||
loc := store.Locator{URL: "url", SiteID: "site"}
|
||||
ts := func(sec int) time.Time { return time.Date(2017, 12, 25, 19, 0, sec, 0, time.UTC) }
|
||||
|
||||
// tree with four top-level comments and subtree sizes 3, 2, 1, 3 (total 9):
|
||||
// c1 -> c1a, c1b
|
||||
// c2 -> c2a
|
||||
// c3
|
||||
// c4 -> c4a -> c4a1
|
||||
comments := []store.Comment{
|
||||
{Locator: loc, ID: "c1", Timestamp: ts(1)},
|
||||
{Locator: loc, ID: "c1a", ParentID: "c1", Timestamp: ts(11)},
|
||||
{Locator: loc, ID: "c1b", ParentID: "c1", Timestamp: ts(12)},
|
||||
{Locator: loc, ID: "c2", Timestamp: ts(2)},
|
||||
{Locator: loc, ID: "c2a", ParentID: "c2", Timestamp: ts(21)},
|
||||
{Locator: loc, ID: "c3", Timestamp: ts(3)},
|
||||
{Locator: loc, ID: "c4", Timestamp: ts(4)},
|
||||
{Locator: loc, ID: "c4a", ParentID: "c4", Timestamp: ts(41)},
|
||||
{Locator: loc, ID: "c4a1", ParentID: "c4a", Timestamp: ts(42)},
|
||||
}
|
||||
|
||||
nodeIDs := func(nodes []*Node) []string {
|
||||
ids := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
ids = append(ids, n.Comment.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
limit int
|
||||
offsetID string
|
||||
wantNodes []string
|
||||
wantLeft int
|
||||
wantLast string
|
||||
}{
|
||||
{"no limit, no offset returns all", 0, "", []string{"c1", "c2", "c3", "c4"}, 0, ""},
|
||||
{"limit equals first subtree size", 3, "", []string{"c1"}, 6, "c1"},
|
||||
{"limit smaller than first subtree returns it whole", 2, "", []string{"c1"}, 6, "c1"},
|
||||
{"limit between first and second boundary stops after first", 4, "", []string{"c1"}, 6, "c1"},
|
||||
{"limit at exact two-subtree boundary includes both", 5, "", []string{"c1", "c2"}, 4, "c2"},
|
||||
{"limit reaches third subtree exactly", 6, "", []string{"c1", "c2", "c3"}, 3, "c3"},
|
||||
{"limit equal to total returns all", 9, "", []string{"c1", "c2", "c3", "c4"}, 0, "c4"},
|
||||
{"limit larger than total returns all", 100, "", []string{"c1", "c2", "c3", "c4"}, 0, "c4"},
|
||||
{"offset only, no limit slices remainder", 0, "c1", []string{"c2", "c3", "c4"}, 0, ""},
|
||||
{"offset at last node clears result", 0, "c4", []string{}, 0, ""},
|
||||
{"offset at last node with limit clears result", 5, "c4", []string{}, 0, ""},
|
||||
{"offset not found starts from beginning", 0, "missing", []string{"c1", "c2", "c3", "c4"}, 0, ""},
|
||||
{"offset plus limit returns single subtree", 2, "c1", []string{"c2"}, 4, "c2"},
|
||||
{"offset plus limit stops before last subtree", 3, "c2", []string{"c3"}, 3, "c3"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := MakeTree(comments, "+time", tc.limit, tc.offsetID)
|
||||
assert.Equal(t, tc.wantNodes, nodeIDs(res.Nodes), "top-level nodes")
|
||||
assert.Equal(t, tc.wantLeft, res.CountLeft(), "count left")
|
||||
assert.Equal(t, tc.wantLast, res.LastComment(), "last comment")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountReplies(t *testing.T) {
|
||||
loc := store.Locator{URL: "url", SiteID: "site"}
|
||||
ts := func(sec int) time.Time { return time.Date(2017, 12, 25, 19, 0, sec, 0, time.UTC) }
|
||||
comments := []store.Comment{
|
||||
{Locator: loc, ID: "c1", Timestamp: ts(1)},
|
||||
{Locator: loc, ID: "c1a", ParentID: "c1", Timestamp: ts(11)},
|
||||
{Locator: loc, ID: "c1b", ParentID: "c1", Timestamp: ts(12)},
|
||||
{Locator: loc, ID: "c4", Timestamp: ts(4)},
|
||||
{Locator: loc, ID: "c4a", ParentID: "c4", Timestamp: ts(41)},
|
||||
{Locator: loc, ID: "c4a1", ParentID: "c4a", Timestamp: ts(42)},
|
||||
}
|
||||
res := MakeTree(comments, "+time", 0, "")
|
||||
|
||||
byID := map[string]*Node{}
|
||||
for _, n := range res.Nodes {
|
||||
byID[n.Comment.ID] = n
|
||||
}
|
||||
|
||||
// guard presence and shape first so a regression in MakeTree fails with a clear
|
||||
// assertion instead of a nil-pointer panic on the map lookups below
|
||||
require.Contains(t, byID, "c1")
|
||||
require.Contains(t, byID, "c4")
|
||||
require.Len(t, byID["c1"].Replies, 2)
|
||||
require.Len(t, byID["c4"].Replies, 1)
|
||||
|
||||
assert.Equal(t, 2, countReplies(byID["c1"]), "c1 has two direct replies, no nesting")
|
||||
assert.Equal(t, 2, countReplies(byID["c4"]), "c4 counts nested reply recursively")
|
||||
assert.Equal(t, 1, countReplies(byID["c4"].Replies[0]), "c4a has one nested reply")
|
||||
assert.Equal(t, 0, countReplies(byID["c1"].Replies[0]), "leaf reply has no replies")
|
||||
}
|
||||
|
||||
func BenchmarkTree(b *testing.B) {
|
||||
comments := []store.Comment{}
|
||||
data, err := os.ReadFile("testdata/tree_bench.json")
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
+6
-8
@@ -7,7 +7,6 @@
|
||||
<meta name="description" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
/* stylelint-disable mavrin/stylelint-declaration-use-css-custom-properties */
|
||||
html {
|
||||
color: #222;
|
||||
font-size: 1em;
|
||||
@@ -182,7 +181,7 @@
|
||||
|
||||
.visuallyhidden {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
@@ -193,7 +192,7 @@
|
||||
|
||||
.visuallyhidden.focusable:active,
|
||||
.visuallyhidden.focusable:focus {
|
||||
clip: auto;
|
||||
clip-path: none;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
overflow: visible;
|
||||
@@ -254,7 +253,7 @@
|
||||
pre,
|
||||
blockquote {
|
||||
border: 1px solid #999;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
thead {
|
||||
@@ -263,7 +262,7 @@
|
||||
|
||||
tr,
|
||||
img {
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
img {
|
||||
@@ -279,7 +278,7 @@
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
page-break-after: avoid;
|
||||
break-after: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -367,8 +366,7 @@
|
||||
|------------ | -------------|
|
||||
|Content from cell 1 | Content from cell 2|
|
||||
|Content in the first column | Content in the second column|
|
||||
</pre
|
||||
>
|
||||
</pre>
|
||||
</article>
|
||||
|
||||
<aside>
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package webassets holds the files served under /web that the frontend build does not produce:
|
||||
// plain pages and images with no dependency on the bundler's output, embedded into the binary.
|
||||
// A file of the same name in the frontend output, on disk under --web-root or embedded at
|
||||
// app/cmd/web, is served instead, which is how an operator replaces one of these.
|
||||
// Email and error-page templates are a separate set and live in app/templates.
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed assets
|
||||
var embedded embed.FS
|
||||
|
||||
// FS holds the assets, each named by its path under /web. fs.Sub cannot fail for a constant
|
||||
// valid path on an embed.FS, so the error is dropped the same way app/cmd/web's is.
|
||||
var FS, _ = fs.Sub(embedded, "assets")
|
||||
@@ -0,0 +1,78 @@
|
||||
package webassets
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFS_Contents(t *testing.T) {
|
||||
entries, err := fs.ReadDir(FS, ".")
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
assert.False(t, e.IsDir(), "the assets are served flat under /web, %s is a directory", e.Name())
|
||||
info, err := e.Info()
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, info.Size(), "%s is empty", e.Name())
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
assert.Equal(t, []string{"400x400.jpeg", "markdown-help.html", "privacy.html"}, names)
|
||||
}
|
||||
|
||||
// TestFS_ContentShape catches an asset that has been truncated or replaced by something of the
|
||||
// wrong kind, which a size check alone lets through.
|
||||
func TestFS_ContentShape(t *testing.T) {
|
||||
tbl := []struct {
|
||||
name string
|
||||
prefix []byte
|
||||
want string
|
||||
}{
|
||||
{name: "400x400.jpeg", prefix: []byte{0xff, 0xd8, 0xff}},
|
||||
{name: "markdown-help.html", want: "<!DOCTYPE html>"},
|
||||
{name: "privacy.html", want: "<!DOCTYPE html>"},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b, err := fs.ReadFile(FS, tt.name)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.prefix != nil {
|
||||
require.GreaterOrEqual(t, len(b), len(tt.prefix))
|
||||
assert.Equal(t, tt.prefix, b[:len(tt.prefix)], "not a JPEG")
|
||||
return
|
||||
}
|
||||
assert.True(t, strings.HasPrefix(strings.TrimSpace(string(b)), tt.want), "not an HTML document")
|
||||
assert.Contains(t, string(b), "</html>", "the document is truncated")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFS_RelativeReferencesResolve keeps the pages self-contained: every relative src and href
|
||||
// they use has to name a sibling that ships alongside them, since nothing else supplies one.
|
||||
func TestFS_RelativeReferencesResolve(t *testing.T) {
|
||||
ref := regexp.MustCompile(`(?:src|href)="([^"]+)"`)
|
||||
external := regexp.MustCompile(`^(?:[a-z]+:|//|#|mailto:)`)
|
||||
|
||||
for _, page := range []string{"markdown-help.html", "privacy.html"} {
|
||||
t.Run(page, func(t *testing.T) {
|
||||
b, err := fs.ReadFile(FS, page)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range ref.FindAllStringSubmatch(string(b), -1) {
|
||||
target := m[1]
|
||||
if external.MatchString(target) {
|
||||
continue
|
||||
}
|
||||
_, err := fs.Stat(FS, target)
|
||||
assert.NoError(t, err, "%s references %q, which ships nowhere", page, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+27
-34
@@ -5,69 +5,62 @@ go 1.25.0
|
||||
require (
|
||||
github.com/Depado/bfchroma/v2 v2.0.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/alecthomas/chroma/v2 v2.24.1
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/didip/tollbooth/v8 v8.0.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-pkgz/auth/v2 v2.1.4
|
||||
github.com/go-pkgz/jrpc v0.4.0
|
||||
github.com/go-pkgz/lcw/v2 v2.0.0
|
||||
github.com/go-pkgz/lgr v0.12.3
|
||||
github.com/go-pkgz/notify v1.3.0
|
||||
github.com/go-pkgz/auth/v2 v2.2.0
|
||||
github.com/go-pkgz/jrpc v0.4.2
|
||||
github.com/go-pkgz/lcw/v2 v2.1.0
|
||||
github.com/go-pkgz/lgr v0.12.4
|
||||
github.com/go-pkgz/notify v1.4.0
|
||||
github.com/go-pkgz/repeater/v2 v2.2.0
|
||||
github.com/go-pkgz/rest v1.21.0
|
||||
github.com/go-pkgz/syncs v1.3.2
|
||||
github.com/go-pkgz/rest v1.24.0
|
||||
github.com/go-pkgz/routegroup v1.6.1
|
||||
github.com/go-pkgz/syncs v1.3.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/feeds v1.2.0
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jessevdk/go-flags v1.6.1
|
||||
github.com/kyokomi/emoji/v2 v2.2.13
|
||||
github.com/kyokomi/emoji/v2 v2.2.14
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
github.com/stretchr/testify v1.12.1
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/image v0.40.0
|
||||
golang.org/x/net v0.54.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.45.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dghubble/oauth1 v0.7.3 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.4 // indirect
|
||||
github.com/go-pkgz/email v0.6.0 // indirect
|
||||
github.com/go-pkgz/expirable-cache/v3 v3.1.0 // indirect
|
||||
github.com/go-pkgz/repeater v1.2.0 // indirect
|
||||
github.com/go-pkgz/routegroup v1.6.0 // indirect
|
||||
github.com/go-pkgz/email v0.8.0 // indirect
|
||||
github.com/go-pkgz/expirable-cache/v3 v3.1.1 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/montanaflynn/stats v0.9.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/montanaflynn/stats v0.12.4 // indirect
|
||||
github.com/redis/go-redis/v9 v9.22.0 // indirect
|
||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect
|
||||
github.com/slack-go/slack v0.23.1 // indirect
|
||||
github.com/slack-go/slack v0.29.0 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.2.0 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
+58
-118
@@ -8,18 +8,16 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
|
||||
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
|
||||
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis/v2 v2.31.1 h1:7XAt0uUg3DtwEKW5ZAGa+K7FZV2DdKQo5K/6TTnfX8Y=
|
||||
github.com/alicebob/miniredis/v2 v2.31.1/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
@@ -34,49 +32,42 @@ github.com/dghubble/oauth1 v0.7.3 h1:EkEM/zMDMp3zOsX2DC/ZQ2vnEX3ELK0/l9kb+vs4ptE
|
||||
github.com/dghubble/oauth1 v0.7.3/go.mod h1:oxTe+az9NSMIucDPDCCtzJGsPhciJV33xocHfcR2sVY=
|
||||
github.com/didip/tollbooth/v8 v8.0.1 h1:VAAapTo1t4Bn6bbpcHjuovwoa9u3JH++wgjbpWv+rB8=
|
||||
github.com/didip/tollbooth/v8 v8.0.1/go.mod h1:oEd9l+ep373d7DmvKLc0a5gasPOev2mTewi6KPQBGJ4=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.4 h1:YjI0tmGW8oxVhn9QSBIxlr641QugWrJY5UWa6XmLcW0=
|
||||
github.com/go-oauth2/oauth2/v4 v4.5.4/go.mod h1:BXiOY+QZtZy2ewbsGk2B5P8TWmtz/Rf7ES5ZttQFxfQ=
|
||||
github.com/go-pkgz/auth/v2 v2.1.4 h1:bCF0vMscOrShF2gelcvKPgskpwQNGCk6AQcoXOf2kbE=
|
||||
github.com/go-pkgz/auth/v2 v2.1.4/go.mod h1:IvxxhJIrwd1hKqFwQgBF9i+sMTmGfzAw66wmhw1zfJc=
|
||||
github.com/go-pkgz/email v0.6.0 h1:snZnXldjeF4PgKSjnx9Fa25mtOgFpAOEeWvnQvrxjLE=
|
||||
github.com/go-pkgz/email v0.6.0/go.mod h1:+wgi4x7S33IuCzfcCM5euN0GwQG6XvO/PBLxrNffYLI=
|
||||
github.com/go-pkgz/expirable-cache/v3 v3.1.0 h1:s05P851/O6QJ6Mc+7o2bh9aGtD3romB1SxDTXifdoqc=
|
||||
github.com/go-pkgz/expirable-cache/v3 v3.1.0/go.mod h1:6pVgNleydKPj0J2/mzrI02/RDo4ivKx5v2XlNmIjhjo=
|
||||
github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY=
|
||||
github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo=
|
||||
github.com/go-pkgz/lcw/v2 v2.0.0 h1:gTwXpiJBhQeA1rXuqkRuLcV79uATFna8CckH8ZBBrH0=
|
||||
github.com/go-pkgz/lcw/v2 v2.0.0/go.mod h1:yxJHOn+IbQBQHxUqkCtMrbGjIfdYcsBAZcVCBaL1Va8=
|
||||
github.com/go-pkgz/lgr v0.12.3 h1:QDug7kRkEsuQtruT9fNF5PVT2kZUqCDPc4GmsgS3fP8=
|
||||
github.com/go-pkgz/lgr v0.12.3/go.mod h1:lpCDgVvCIxBHZp8+sGCj9MPctIzKZyZ3QdE19ddqd54=
|
||||
github.com/go-pkgz/notify v1.3.0 h1:YxF/ThEoCetdcoghWdyeqaBpCkZ8mvyve7HXbCAOzYU=
|
||||
github.com/go-pkgz/notify v1.3.0/go.mod h1:qdfi5OsViKlIFPryIOaINHTOtS9GFhOYXPqJmAMlaGU=
|
||||
github.com/go-pkgz/repeater v1.2.0 h1:oJFvjyKdTDd5RCzpzxlzYIZFFj6Zfl17rE1aUfu6UjQ=
|
||||
github.com/go-pkgz/repeater v1.2.0/go.mod h1:vypP6xamA53MFmafnGUucqOmALKk36xgKu2hSG73LHM=
|
||||
github.com/go-pkgz/auth/v2 v2.2.0 h1:vQO+GTFDjAaBNSdcLLLr3Xibka67GZPtkRRItVVS4ow=
|
||||
github.com/go-pkgz/auth/v2 v2.2.0/go.mod h1:iZx2JiGZ8Aef+wM0BPLMQY8aur4fLE0uyPhFRO9dYQ4=
|
||||
github.com/go-pkgz/email v0.8.0 h1:6+Tgjfj7zFccFCPmURV2spKDXDb7aX/iWXBY2hBa9ww=
|
||||
github.com/go-pkgz/email v0.8.0/go.mod h1:+wgi4x7S33IuCzfcCM5euN0GwQG6XvO/PBLxrNffYLI=
|
||||
github.com/go-pkgz/expirable-cache/v3 v3.1.1 h1:ryHiSI5gBE8aJ0Jt90VFMKZxe/cosf2dZPDcUTMaHNg=
|
||||
github.com/go-pkgz/expirable-cache/v3 v3.1.1/go.mod h1:peJAuIDjP76Uuc9NK55ljQlBtwwmJDvx4CnMyUcsP40=
|
||||
github.com/go-pkgz/jrpc v0.4.2 h1:gY5mmxp9/dFd1WsHybVZILQpF11YNWWS3Ga+Pc5aIAU=
|
||||
github.com/go-pkgz/jrpc v0.4.2/go.mod h1:ZtnMpIXYmwXh6W44XO2lE5Lh5J+6KeeMIvw+vF9xXRQ=
|
||||
github.com/go-pkgz/lcw/v2 v2.1.0 h1:JAGUHRQPon658XimxIUwaqPgOPfIKa850qO8jpFJchc=
|
||||
github.com/go-pkgz/lcw/v2 v2.1.0/go.mod h1:UUo4cgD6oTPooBuUslVaWqOYZAGnh/91SoaB5DBWC8s=
|
||||
github.com/go-pkgz/lgr v0.12.4 h1:lDeQ4BR28ldXrKau6BOjq7A8nHzcXz+MF4xUfV4l1Ok=
|
||||
github.com/go-pkgz/lgr v0.12.4/go.mod h1:Lw6DkNRnCPyX07mqkiUK/p+eA1opq4GKkWfWia64RA8=
|
||||
github.com/go-pkgz/notify v1.4.0 h1:4pP7UGdYqFO7e7V3OsQStYF006CO0cCh1ahdawt6l18=
|
||||
github.com/go-pkgz/notify v1.4.0/go.mod h1:UFpL9ZvCYnLBEjeay++3afh8GceZ6qT8wj5lqfSR9U4=
|
||||
github.com/go-pkgz/repeater/v2 v2.2.0 h1:8nZR/NaknmLfx2YMHbr78u9OL4Xj+8+romm9dz4FpMg=
|
||||
github.com/go-pkgz/repeater/v2 v2.2.0/go.mod h1:RgX5vUbLKq7PV82QUDP5pFbQS1os4Z+U9XzKymK23A8=
|
||||
github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4=
|
||||
github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE=
|
||||
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
|
||||
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-pkgz/syncs v1.3.2 h1:gmioASlJNy3gNosPlgvWOM2QP0Hdjzn2u+/sUShgd8E=
|
||||
github.com/go-pkgz/syncs v1.3.2/go.mod h1:qjgzpp7OpuhDf7BWsW/FHCu9DLjE32NPy6/vXAXT/Cw=
|
||||
github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
|
||||
github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
|
||||
github.com/go-pkgz/routegroup v1.6.1 h1:6I/0LabazpZsHAI+jYPeyH/KU2cvZF0bFylUScMNi+Q=
|
||||
github.com/go-pkgz/routegroup v1.6.1/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/go-pkgz/syncs v1.3.3 h1:fFRK+eqCIFddxEiDi6ob5oQh3/NuNkjuwWQJzZ0b9lU=
|
||||
github.com/go-pkgz/syncs v1.3.3/go.mod h1:lAp+w+qbRm6+pwINcfc9BVK+4F4NEoERYditsF1uENA=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
@@ -91,11 +82,6 @@ github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
|
||||
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
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/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
@@ -106,26 +92,24 @@ github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bB
|
||||
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
|
||||
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/montanaflynn/stats v0.12.4 h1:amtNRsti20yIhcrkfUJGwoYqBR82jKQFE8SNNYVgGn0=
|
||||
github.com/montanaflynn/stats v0.12.4/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
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/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d h1:l3+2LWCbVxn5itfvXAfH9n4YL9jh8l1g5zcncbIc1cs=
|
||||
@@ -138,20 +122,20 @@ github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/slack-go/slack v0.23.1 h1:ZS5B96wxxYQRwvJ3/vJFtqtUZi3tXhsZCyT44Nv7M80=
|
||||
github.com/slack-go/slack v0.23.1/go.mod h1:H0yR/YBuRJ39RkE+JpV/d/oEsbanzTRowR82bCN0cEs=
|
||||
github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI=
|
||||
github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
|
||||
github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0=
|
||||
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
||||
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
||||
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
|
||||
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
|
||||
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
@@ -191,94 +175,50 @@ github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
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/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
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/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
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/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+111
-78
@@ -1,89 +1,122 @@
|
||||
version: "2"
|
||||
run:
|
||||
tests: true
|
||||
|
||||
output:
|
||||
print-issued-lines: false
|
||||
|
||||
show-stats: false
|
||||
formats:
|
||||
text:
|
||||
print-issued-lines: false
|
||||
colors: true
|
||||
linters:
|
||||
enable-all: true
|
||||
default: all
|
||||
disable:
|
||||
- lll
|
||||
- gocyclo
|
||||
- dupl
|
||||
- gochecknoglobals
|
||||
- funlen
|
||||
- godox
|
||||
- wsl
|
||||
- gocognit
|
||||
- nolintlint
|
||||
- testpackage
|
||||
- godot
|
||||
- nestif
|
||||
- paralleltest
|
||||
- nlreturn
|
||||
- cyclop
|
||||
- gci
|
||||
- gofumpt
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- wrapcheck
|
||||
- stylecheck
|
||||
- thelper
|
||||
- nonamedreturns
|
||||
- revive
|
||||
- dupword
|
||||
- exhaustruct
|
||||
- varnamelen
|
||||
- forcetypeassert
|
||||
- ireturn
|
||||
- maintidx
|
||||
- govet
|
||||
- testableexamples
|
||||
- musttag
|
||||
- prealloc
|
||||
- dupl
|
||||
- godoclint
|
||||
- cyclop
|
||||
- depguard
|
||||
- goconst
|
||||
- perfsprint
|
||||
- dupword
|
||||
- err113
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustruct
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funlen
|
||||
- gochecknoglobals
|
||||
- gocognit
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godot
|
||||
- godox
|
||||
- gomoddirectives
|
||||
- ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- mnd
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- nolintlint
|
||||
- nonamedreturns
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- predeclared
|
||||
- recvcheck
|
||||
- tenv
|
||||
- err113
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 10
|
||||
dupl:
|
||||
threshold: 100
|
||||
goconst:
|
||||
min-len: 8
|
||||
min-occurrences: 3
|
||||
forbidigo:
|
||||
#forbid:
|
||||
# - (Must)?NewLexer$
|
||||
exclude_godoc_examples: false
|
||||
|
||||
|
||||
- revive
|
||||
- testpackage
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- wsl
|
||||
- wsl_v5
|
||||
- funcorder
|
||||
- noinlineerr
|
||||
- tagalign
|
||||
- goconst
|
||||
- gochecknoinits
|
||||
- durationcheck
|
||||
- embeddedstructfieldcheck
|
||||
- wrapcheck
|
||||
- gomodguard
|
||||
settings:
|
||||
dupl:
|
||||
threshold: 100
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
goconst:
|
||||
min-len: 8
|
||||
min-occurrences: 3
|
||||
gocyclo:
|
||||
min-complexity: 10
|
||||
wrapcheck:
|
||||
report-internal-errors: false
|
||||
ignore-package-globs:
|
||||
- github.com/alecthomas/errors
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- path: (.+)\.go$
|
||||
text: "^(G104|G204|G307|G304):"
|
||||
- path: (.+)\.go$
|
||||
text: Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked
|
||||
- path: (.+)\.go$
|
||||
text: exported method `(.*\.MarshalJSON|.*\.UnmarshalJSON|.*\.EntityURN|.*\.GoString|.*\.Pos)` should have comment or be unexported
|
||||
- path: (.+)\.go$
|
||||
text: uses unkeyed fields
|
||||
- path: (.+)\.go$
|
||||
text: declaration of "err" shadows declaration
|
||||
- path: (.+)\.go$
|
||||
text: bad syntax for struct tag key
|
||||
- path: (.+)\.go$
|
||||
text: bad syntax for struct tag pair
|
||||
- path: (.+)\.go$
|
||||
text: ^ST1012
|
||||
- path: (.+)\.go$
|
||||
text: log/slog.Logger.*must not be called
|
||||
- path: (.+)_test\.go$
|
||||
text: error returned from external package is unwrapped
|
||||
- linters: [staticcheck]
|
||||
text: QF1008
|
||||
- text: "Error return value of `.*.Write` is not checked"
|
||||
linters: [errcheck]
|
||||
path: (.+)_test\.go$
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- _examples
|
||||
max-per-linter: 0
|
||||
max-same: 0
|
||||
exclude-use-default: false
|
||||
exclude:
|
||||
# Captured by errcheck.
|
||||
- '^(G104|G204):'
|
||||
# Very commonly not checked.
|
||||
- 'Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked'
|
||||
- 'exported method (.*\.MarshalJSON|.*\.UnmarshalJSON|.*\.EntityURN|.*\.GoString|.*\.Pos) should have comment or be unexported'
|
||||
- 'composite literal uses unkeyed fields'
|
||||
- 'declaration of "err" shadows declaration'
|
||||
- 'should not use dot imports'
|
||||
- 'Potential file inclusion via variable'
|
||||
- 'should have comment or be unexported'
|
||||
- 'comment on exported var .* should be of the form'
|
||||
- 'at least one file in a package should have a package comment'
|
||||
- 'string literal contains the Unicode'
|
||||
- 'methods on the same type should have the same receiver name'
|
||||
- '_TokenType_name should be _TokenTypeName'
|
||||
- '`_TokenType_map` should be `_TokenTypeMap`'
|
||||
- 'rewrite if-else to switch statement'
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Chroma is a syntax highlighting library, tool and web playground for Go. It is based on Pygments and includes importers for it, so most of the same concepts from Pygments apply to Chroma.
|
||||
|
||||
This project is written in Go, uses Hermit to manage tooling, and Just for helper commands. Helper scripts are in ./scripts.
|
||||
This project is written in Go, uses Hermit to manage tooling, and Just for helper commands. Helper tooling is primarily in ./_tools.
|
||||
|
||||
Language definitions are XML files defined in ./lexers/embedded/*.xml.
|
||||
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
let version = exec("git describe --tags --dirty --always") | trim
|
||||
# TinyGo's installation root; used to source `wasm_exec.js`.
|
||||
let tinygoroot = exec("tinygo env TINYGOROOT") | trim
|
||||
|
||||
|
||||
# Generate tokentype_enumer.go from types.go via `//go:generate`.
|
||||
tokentype = go.generate {
|
||||
package = "."
|
||||
inputs = ["types.go"]
|
||||
outputs = ["tokentype_enumer.go"]
|
||||
}
|
||||
|
||||
# Regenerate the lexer table in README.md by invoking the host `chroma` binary.
|
||||
# GOOS/GOARCH are cleared so cross-compile env vars don't break the local run.
|
||||
protected readme = exec {
|
||||
command = "./table.py"
|
||||
inputs = ["table.py", "lexers/**/*.go", "lexers/**/*.xml"]
|
||||
output = "README.md"
|
||||
}
|
||||
|
||||
# Format frontend JS sources in place. Runs as a sub-step of `index-min-js`,
|
||||
# so bundling always sees formatted sources.
|
||||
format-js = exec {
|
||||
command = "biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js"
|
||||
inputs = ["biome.js", "cmd/chromad/static/index.js", "cmd/chromad/static/chroma.js"]
|
||||
}
|
||||
|
||||
# Copy TinyGo's wasm_exec.js into the chromad static assets.
|
||||
wasm-exec = exec {
|
||||
command = "install -m644 '#{tinygoroot}/targets/wasm_exec.js' cmd/chromad/static/wasm_exec.js"
|
||||
resolve = "sha256 '#{tinygoroot}/targets/wasm_exec.js'"
|
||||
output = "cmd/chromad/static/wasm_exec.js"
|
||||
}
|
||||
|
||||
# Build the chroma WASM module via tinygo (installed via hermit) for the
|
||||
# smaller output binary.
|
||||
chroma-wasm = exec {
|
||||
command = "tinygo build -no-debug -target wasm -o cmd/chromad/static/chroma.wasm cmd/libchromawasm/main.go"
|
||||
inputs = ["cmd/libchromawasm/**/*.go", "*.go", "lexers/**/*.go", "lexers/**/*.xml", "formatters/**/*.go", "styles/**/*.go"]
|
||||
output = "cmd/chromad/static/chroma.wasm"
|
||||
}
|
||||
|
||||
# Bundle and minify the frontend JS. Depends on `format-js` so the bundle
|
||||
# always reflects formatted sources.
|
||||
index-min-js = exec {
|
||||
command = "esbuild --platform=browser --format=esm --bundle cmd/chromad/static/index.js --minify --external:./wasm_exec.js --outfile=cmd/chromad/static/index.min.js"
|
||||
inputs = ["cmd/chromad/static/index.js", "cmd/chromad/static/chroma.js"]
|
||||
output = "cmd/chromad/static/index.min.js"
|
||||
depends_on = [format-js]
|
||||
}
|
||||
|
||||
# Bundle and minify the frontend CSS.
|
||||
index-min-css = exec {
|
||||
command = "esbuild --bundle cmd/chromad/static/index.css --minify --outfile=cmd/chromad/static/index.min.css"
|
||||
inputs = ["cmd/chromad/static/index.css", "cmd/chromad/static/bulma.css"]
|
||||
output = "cmd/chromad/static/index.min.css"
|
||||
}
|
||||
|
||||
# Build the chromad server binary. cmd/chromad is a separate Go module, so
|
||||
# `dir` puts the build in there and `package = "."` resolves against that
|
||||
# module. `output` stays project-root-relative; bit absolutises it before
|
||||
# passing to `go build -o`. Defaults to linux/amd64 to match the deploy
|
||||
# target; override with GOOS/GOARCH env vars for local builds.
|
||||
chromad = go.exe {
|
||||
dir = "cmd/chromad"
|
||||
package = "."
|
||||
output = "build/chromad"
|
||||
flags = ["-ldflags", "-X 'main.version=#{version}'"]
|
||||
goos = env("GOOS", "linux")
|
||||
goarch = env("GOARCH", "amd64")
|
||||
cgo = false
|
||||
depends_on = [wasm-exec, chroma-wasm, index-min-js, index-min-css, test]
|
||||
}
|
||||
|
||||
pre format-go = go.fmt {
|
||||
package = "./..."
|
||||
}
|
||||
|
||||
# Run Go tests.
|
||||
test = go.test {
|
||||
package = "./..."
|
||||
}
|
||||
|
||||
# Deploy chromad to swapoff.org. Must be explicitly selected.
|
||||
explicit upload = exec {
|
||||
command = <<-EOF
|
||||
scp #{chromad.path} root@swapoff.org:
|
||||
ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
|
||||
EOF
|
||||
depends_on = [chromad]
|
||||
}
|
||||
|
||||
target default = [test, chromad, readme, tokentype]
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
VERSION = %(git describe --tags --dirty --always)%
|
||||
export CGOENABLED = 0
|
||||
|
||||
tokentype_enumer.go: types.go
|
||||
build: go generate
|
||||
|
||||
# Regenerate the list of lexers in the README
|
||||
README.md: lexers/*.go lexers/*/*.xml table.py
|
||||
build: ./table.py
|
||||
-clean
|
||||
|
||||
implicit %{1}%{2}.min.%{3}: **/*.{css,js}
|
||||
build: esbuild --bundle %{IN} --minify --outfile=%{OUT}
|
||||
|
||||
implicit build/%{1}: cmd/*
|
||||
cd cmd/%{1}
|
||||
inputs: cmd/%{1}/**/* **/*.go
|
||||
build: go build -ldflags="-X 'main.version=%{VERSION}'" -o ../../build/%{1} .
|
||||
|
||||
#upload: chromad
|
||||
# build:
|
||||
# scp chromad root@swapoff.org:
|
||||
# ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
|
||||
# touch upload
|
||||
+1
-1
@@ -28,7 +28,7 @@ ENV GOARCH=amd64
|
||||
RUN just chromad
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.23 AS runtime
|
||||
FROM alpine:3.24 AS runtime
|
||||
|
||||
# Install ca-certificates for HTTPS requests
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
set positional-arguments := true
|
||||
set positional-arguments
|
||||
set shell := ["bash", "-c"]
|
||||
|
||||
version := `git describe --tags --dirty --always`
|
||||
@@ -21,6 +21,10 @@ tokentype-string:
|
||||
format-js:
|
||||
biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js
|
||||
|
||||
# Tidy Go modules
|
||||
tidy:
|
||||
find . -name 'go.mod' -execdir go mod tidy \;
|
||||
|
||||
# Build chromad binary
|
||||
chromad: wasm-exec chroma-wasm
|
||||
#!/usr/bin/env bash
|
||||
|
||||
+9
-7
@@ -36,25 +36,25 @@ translators for Pygments lexers and styles.
|
||||
|
||||
| Prefix | Language
|
||||
| :----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
| A | ABAP, ABNF, ActionScript, ActionScript 3, Ada, Agda, AL, Alloy, AMPL, Angular2, ANTLR, ApacheConf, APL, AppleScript, ArangoDB AQL, Arduino, ArmAsm, ATL, AutoHotkey, AutoIt, Awk
|
||||
| A | ABAP, ABNF, ActionScript, ActionScript 3, Ada, Agda, AL, Alloy, AMPL, Angular2, ANTLR, ApacheConf, APL, AppleScript, ArangoDB AQL, Arduino, ArmAsm, Arturo, ATL, AutoHotkey, AutoIt, Awk
|
||||
| B | Ballerina, Bash, Bash Session, Batchfile, Beef, BibTeX, Bicep, BlitzBasic, BNF, BQN, Brainfuck
|
||||
| C | C, C#, C++, C3, Caddyfile, Caddyfile Directives, Cap'n Proto, Cassandra CQL, Ceylon, CFEngine3, cfstatement, ChaiScript, Chapel, Cheetah, Clojure, CMake, COBOL, CoffeeScript, Common Lisp, Coq, Core, Crystal, CSS, CSV, CUE, Cython
|
||||
| D | D, Dart, Dax, Desktop file, Diff, Django/Jinja, dns, Docker, DTD, Dylan
|
||||
| E | EBNF, Elixir, Elm, EmacsLisp, Erlang
|
||||
| D | D, Dart, Dax, Desktop file, Devicetree, Diff, Django/Jinja, dns, Docker, DTD, Dylan
|
||||
| E | EBNF, Elixir, Elm, EmacsLisp, ERB, Erlang
|
||||
| F | Factor, Fennel, Fish, Forth, Fortran, FortranFixed, FSharp
|
||||
| G | GAS, GDScript, GDScript3, Gemtext, Genshi, Genshi HTML, Genshi Text, Gettext, Gherkin, Gleam, GLSL, Gnuplot, Go, Go HTML Template, Go Template, Go Text Template, GraphQL, Groff, Groovy
|
||||
| H | Handlebars, Hare, Haskell, Haxe, HCL, Hexdump, HLB, HLSL, HolyC, HTML, HTTP, Hy
|
||||
| I | Idris, Igor, INI, Io, ISCdhcpd
|
||||
| J | J, Janet, Java, JavaScript, JSON, JSONata, Jsonnet, Julia, Jungle
|
||||
| K | Kakoune, Kotlin
|
||||
| L | Lean4, Lighttpd configuration file, LLVM, lox, Lua, Luau
|
||||
| M | Makefile, Mako, markdown, Markless, Mason, Materialize SQL dialect, Mathematica, Matlab, MCFunction, Meson, Metal, MiniZinc, MLIR, Modelica, Modula-2, Mojo, MonkeyC, MoonScript, MorrowindScript, Myghty, MySQL
|
||||
| K | Kakoune, KDL, Kotlin
|
||||
| L | Lateralus, Lean4, Lighttpd configuration file, LilyPond, LLVM, lox, Lua, Luau
|
||||
| M | Makefile, Mako, markdown, Markless, Mason, Materialize SQL dialect, Mathematica, Matlab, MCFunction, Meson, Metal, microcad, MiniZinc, MLIR, Modelica, Modula-2, Mojo, MonkeyC, MoonBit, MoonScript, MorrowindScript, Myghty, MySQL
|
||||
| N | NASM, Natural, NDISASM, Newspeak, Nginx configuration file, Nim, Nix, NSIS, Nu
|
||||
| O | Objective-C, ObjectPascal, OCaml, Octave, Odin, OnesEnterprise, OpenEdge ABL, OpenSCAD, Org Mode
|
||||
| P | PacmanConf, Perl, PHP, PHTML, Pig, PkgConfig, PL/pgSQL, plaintext, Plutus Core, Pony, PostgreSQL SQL dialect, PostScript, POVRay, PowerQuery, PowerShell, Prolog, Promela, PromQL, properties, Protocol Buffer, Protocol Buffer Text Format, PRQL, PSL, Puppet, Python, Python 2
|
||||
| Q | QBasic, QML
|
||||
| R | R, Racket, Ragel, Raku, react, ReasonML, reg, Rego, reStructuredText, Rexx, RGBDS Assembly, Ring, RPGLE, RPMSpec, Ruby, Rust
|
||||
| S | SAS, Sass, Scala, Scheme, Scilab, SCSS, Sed, Sieve, Smali, Smalltalk, Smarty, SNBT, Snobol, Solidity, SourcePawn, Spade, SPARQL, SQL, SquidConf, Standard ML, stas, Stylus, Svelte, Swift, SYSTEMD, systemverilog
|
||||
| S | SAS, Sass, Scala, scdoc, Scheme, Scilab, SCSS, Sed, Sieve, Smali, Smalltalk, Smarty, SNBT, Snobol, Solidity, SourcePawn, Spade, SPARQL, SQL, SquidConf, Standard ML, stas, Stylus, Svelte, Swift, SYSTEMD, systemverilog
|
||||
| T | TableGen, Tal, TASM, Tcl, Tcsh, Termcap, Terminfo, Terraform, TeX, Thrift, TOML, TradingView, Transact-SQL, Turing, Turtle, Twig, TypeScript, TypoScript, TypoScriptCssData, TypoScriptHtmlData, Typst
|
||||
| U | ucode
|
||||
| V | V, V shell, Vala, VB.net, verilog, VHDL, VHS, VimL, vue
|
||||
@@ -276,6 +276,8 @@ for that setup the `chroma` executable can be just symlinked to `~/.lessfilter`.
|
||||
its input using Chroma
|
||||
* [Hugo](https://gohugo.io/) is a static site generator that [uses Chroma for syntax
|
||||
highlighting code examples](https://gohugo.io/content-management/syntax-highlighting/)
|
||||
* [f4](https://github.com/unxed/f4) is asynchronious cross platform Far Manager clone in Go
|
||||
that uses Chroma for syntax highlighting in built-in editor
|
||||
|
||||
## Testing lexers
|
||||
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ type Colour int32
|
||||
|
||||
// NewColour creates a Colour directly from RGB values.
|
||||
func NewColour(r, g, b uint8) Colour {
|
||||
return ParseColour(fmt.Sprintf("%02x%02x%02x", r, g, b))
|
||||
return Colour(int32(r)<<16|int32(g)<<8|int32(b)) + 1
|
||||
}
|
||||
|
||||
// Distance between this colour and another.
|
||||
|
||||
+57
-13
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -83,6 +84,11 @@ func WithPreWrapper(wrapper PreWrapper) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithModeClasses adds the style's mode (eg. "light" or "dark") as a CSS
|
||||
// class on wrapper elements and scopes WriteCSS rules by mode. This enables
|
||||
// combining light and dark stylesheets and switching themes at runtime.
|
||||
func WithModeClasses(b bool) Option { return func(f *Formatter) { f.modeClasses = b } }
|
||||
|
||||
// WrapLongLines wraps long lines.
|
||||
func WrapLongLines(b bool) Option {
|
||||
return func(f *Formatter) {
|
||||
@@ -206,6 +212,7 @@ type Formatter struct {
|
||||
inlineCode bool
|
||||
preventSurroundingPre bool
|
||||
tabWidth int
|
||||
modeClasses bool
|
||||
wrapLongLines bool
|
||||
lineNumbers bool
|
||||
lineNumbersInTable bool
|
||||
@@ -241,7 +248,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
|
||||
fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
|
||||
fmt.Fprint(w, "</style>")
|
||||
}
|
||||
fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background))
|
||||
fmt.Fprintf(w, "<body%s>\n", f.styleAttrWithMode(css, chroma.Background, style))
|
||||
}
|
||||
|
||||
wrapInTable := f.lineNumbers && f.lineNumbersInTable
|
||||
@@ -252,10 +259,10 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
|
||||
|
||||
if wrapInTable {
|
||||
// List line numbers in its own <td>
|
||||
fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.PreWrapper))
|
||||
fmt.Fprintf(w, "<div%s>\n", f.styleAttrWithMode(css, chroma.PreWrapper, style))
|
||||
fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
|
||||
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
|
||||
fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttr(css, chroma.PreWrapper)))
|
||||
fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttrWithMode(css, chroma.PreWrapper, style)))
|
||||
for index := range lines {
|
||||
line := f.baseLineNumber + index
|
||||
highlight, next := f.shouldHighlight(highlightIndex, line)
|
||||
@@ -277,7 +284,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
|
||||
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttr(css, chroma.PreWrapper)))
|
||||
fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttrWithMode(css, chroma.PreWrapper, style)))
|
||||
|
||||
highlightIndex = 0
|
||||
for index, tokens := range lines {
|
||||
@@ -288,7 +295,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
|
||||
highlightIndex++
|
||||
}
|
||||
|
||||
if !(f.preventSurroundingPre || f.inlineCode) {
|
||||
if !f.preventSurroundingPre && !f.inlineCode {
|
||||
// Start of Line
|
||||
fmt.Fprint(w, `<span`)
|
||||
|
||||
@@ -321,7 +328,7 @@ func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.
|
||||
fmt.Fprint(w, html)
|
||||
}
|
||||
|
||||
if !(f.preventSurroundingPre || f.inlineCode) {
|
||||
if !f.preventSurroundingPre && !f.inlineCode {
|
||||
fmt.Fprint(w, `</span>`) // End of CodeLine
|
||||
|
||||
fmt.Fprint(w, `</span>`) // End of Line
|
||||
@@ -414,6 +421,26 @@ func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.Toke
|
||||
return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
|
||||
}
|
||||
|
||||
// modeClass returns the CSS class corresponding to the style's mode (eg.
|
||||
// "light" or "dark"), with the formatter's class prefix applied.
|
||||
func (f *Formatter) modeClass(style *chroma.Style) string {
|
||||
return f.prefix + style.Mode().String()
|
||||
}
|
||||
|
||||
// styleAttrWithMode is like styleAttr but, in classes mode, appends the
|
||||
// style's mode class alongside the existing class. Used for the outer
|
||||
// wrapper and standalone <body> so external CSS can target the mode.
|
||||
func (f *Formatter) styleAttrWithMode(styles map[chroma.TokenType]string, tt chroma.TokenType, style *chroma.Style) string {
|
||||
if !f.Classes || !f.modeClasses {
|
||||
return f.styleAttr(styles, tt)
|
||||
}
|
||||
cls := f.class(tt)
|
||||
if cls == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(` class="%s %s"`, cls, f.modeClass(style))
|
||||
}
|
||||
|
||||
func (f *Formatter) tabWidthStyle() string {
|
||||
if f.tabWidth != 0 && f.tabWidth != 8 {
|
||||
return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth)
|
||||
@@ -437,20 +464,38 @@ func (f *Formatter) writeCSSRule(w io.Writer, comment string, selector string, s
|
||||
}
|
||||
|
||||
// WriteCSS writes CSS style definitions (without any surrounding HTML).
|
||||
//
|
||||
// Rules are scoped by the style's mode (eg. ".chroma.dark") so that CSS
|
||||
// generated from a light and dark style can be combined without conflict.
|
||||
// To support dynamic theme switching, call WriteCSS with both styles,
|
||||
// concatenate the output, and toggle the wrapper's mode class (added
|
||||
// automatically by Format) at runtime. Tokens that one theme leaves
|
||||
// unstyled fall back to that theme's ".chroma.<mode>" text/background
|
||||
// via the CSS cascade; pass WithAllClasses(true) if you need every
|
||||
// token's rule materialised explicitly for both themes.
|
||||
func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
|
||||
css := f.styleCache.get(style, false)
|
||||
var chromaSel, bgSel string
|
||||
if f.modeClasses {
|
||||
modeCls := f.modeClass(style)
|
||||
chromaSel = fmt.Sprintf(".%schroma.%s", f.prefix, modeCls)
|
||||
bgSel = fmt.Sprintf(".%sbg.%s", f.prefix, modeCls)
|
||||
} else {
|
||||
chromaSel = fmt.Sprintf(".%schroma", f.prefix)
|
||||
bgSel = fmt.Sprintf(".%sbg", f.prefix)
|
||||
}
|
||||
|
||||
// Special-case background as it is mapped to the outer ".chroma" class.
|
||||
if err := f.writeCSSRule(w, chroma.Background.String(), fmt.Sprintf(".%sbg", f.prefix), css[chroma.Background]); err != nil {
|
||||
if err := f.writeCSSRule(w, chroma.Background.String(), bgSel, css[chroma.Background]); err != nil {
|
||||
return err
|
||||
}
|
||||
// Special-case PreWrapper as it is the ".chroma" class.
|
||||
if err := f.writeCSSRule(w, chroma.PreWrapper.String(), fmt.Sprintf(".%schroma", f.prefix), css[chroma.PreWrapper]); err != nil {
|
||||
if err := f.writeCSSRule(w, chroma.PreWrapper.String(), chromaSel, css[chroma.PreWrapper]); err != nil {
|
||||
return err
|
||||
}
|
||||
// Special-case code column of table to expand width.
|
||||
if f.lineNumbers && f.lineNumbersInTable {
|
||||
selector := fmt.Sprintf(".%schroma .%s:last-child", f.prefix, f.class(chroma.LineTableTD))
|
||||
selector := fmt.Sprintf("%s .%s:last-child", chromaSel, f.class(chroma.LineTableTD))
|
||||
if err := f.writeCSSRule(w, chroma.LineTableTD.String(), selector, "width: 100%;"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -460,7 +505,7 @@ func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
|
||||
targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight))
|
||||
for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} {
|
||||
comment := fmt.Sprintf("%s targeted by URL anchor", tt)
|
||||
selector := fmt.Sprintf(".%schroma .%s:target", f.prefix, f.class(tt))
|
||||
selector := fmt.Sprintf("%s .%s:target", chromaSel, f.class(tt))
|
||||
if err := f.writeCSSRule(w, comment, selector, targetedLineCSS); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -481,7 +526,7 @@ func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
|
||||
if class == "" {
|
||||
continue
|
||||
}
|
||||
if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf(".%schroma .%s", f.prefix, class), css[tt]); err != nil {
|
||||
if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf("%s .%s", chromaSel, class), css[tt]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -613,8 +658,7 @@ func (l *styleCache) get(style *chroma.Style, compress bool) map[chroma.TokenTyp
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Look for an existing entry.
|
||||
for i := len(l.cache) - 1; i >= 0; i-- {
|
||||
entry := l.cache[i]
|
||||
for i, entry := range slices.Backward(l.cache) {
|
||||
if entry.style == style && entry.compressed == compress {
|
||||
// Top of the cache, no need to adjust the order.
|
||||
if i == len(l.cache)-1 {
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
|
||||
<lexer>
|
||||
<config>
|
||||
<name>Arturo</name>
|
||||
<alias>arturo</alias>
|
||||
<alias>art</alias>
|
||||
<filename>*.art</filename>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
<rule pattern=";.*?$"><token type="CommentSingle"/></rule>
|
||||
<rule pattern="^((\s#!)|(#!)).*?$"><token type="CommentHashbang"/></rule>
|
||||
<rule pattern="(false|true|maybe)\b"><token type="NameConstant"/></rule>
|
||||
<rule pattern="\b(this|init)\b\??:?"><token type="NameBuiltinPseudo"/></rule>
|
||||
<rule pattern="`.`"><token type="LiteralStringChar"/></rule>
|
||||
<rule pattern="\\\w+\b\??:?"><token type="NameProperty"/></rule>
|
||||
<rule pattern="#\w+"><token type="NameConstant"/></rule>
|
||||
<rule pattern="\b[0-9]+\.[0-9]+"><token type="LiteralNumberFloat"/></rule>
|
||||
<rule pattern="\b[0-9]+"><token type="LiteralNumberInteger"/></rule>
|
||||
<rule pattern="\w+\b\??:"><token type="NameLabel"/></rule>
|
||||
<rule pattern="\'(?:\w+\b\??:?)"><token type="KeywordDeclaration"/></rule>
|
||||
<rule pattern="\:\w+"><token type="KeywordType"/></rule>
|
||||
<rule pattern="\.\w+\??:?"><token type="NameAttribute"/></rule>
|
||||
<rule pattern="(\()(.*?)(\)\?)"><bygroups><token type="Punctuation"/><usingself state="root"/><token type="Punctuation"/></bygroups></rule>
|
||||
<rule pattern="""><token type="LiteralStringDouble"/><push state="inside-simple-string"/></rule>
|
||||
<rule pattern="»"><token type="LiteralStringSingle"/><push state="inside-smart-string"/></rule>
|
||||
<rule pattern="«««"><token type="LiteralStringDouble"/><push state="inside-safe-string"/></rule>
|
||||
<rule pattern="\{\/"><token type="LiteralStringSingle"/><push state="inside-regex-string"/></rule>
|
||||
<rule pattern="\{\:"><token type="LiteralStringDouble"/><push state="inside-curly-verb-string"/></rule>
|
||||
<rule pattern="(\{)(\!)(\w+)(\s|\n)([\w\W]*?)(^\})">
|
||||
<usingbygroup>
|
||||
<sublexer_name_group>3</sublexer_name_group>
|
||||
<code_group>5</code_group>
|
||||
<emitters>
|
||||
<token type="LiteralStringDouble"/>
|
||||
<token type="LiteralStringInterpol"/>
|
||||
<token type="LiteralStringInterpol"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="LiteralString"/>
|
||||
<token type="LiteralStringDouble"/>
|
||||
</emitters>
|
||||
</usingbygroup>
|
||||
</rule>
|
||||
<rule pattern="\{"><token type="LiteralStringSingle"/><push state="inside-curly-string"/></rule>
|
||||
<rule pattern="\-{3,}"><token type="LiteralStringSingle"/><push state="inside-eof-string"/></rule>
|
||||
<rule><include state="builtin-functions"/></rule>
|
||||
<rule pattern="[()[\],]"><token type="Punctuation"/></rule>
|
||||
<rule pattern="(\->|==>|\||::|@|\#|\$|\&|!|!!|\./)"><token type="NameDecorator"/></rule>
|
||||
<rule pattern="(<:|:>|:<|>:|<\\|<>|<|>|ø|∞|\+|\-|\*|\~|=|\^|%|/|//|==>|<=>|<==>|=>>|<<=>>|<<==>>|\-\->|<\->|<\-\->|=\||\|=|\-:|:\-|_|\.|\.\.|\\)"><token type="Operator"/></rule>
|
||||
<rule pattern="\b\w+"><token type="Name"/></rule>
|
||||
<rule pattern="\s+"><token type="TextWhitespace"/></rule>
|
||||
<rule pattern=".+$"><token type="Error"/></rule>
|
||||
</state>
|
||||
<state name="inside-interpol">
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|]+"><usingself state="root"/></rule>
|
||||
</state>
|
||||
<state name="inside-template">
|
||||
<rule pattern="\|\|\>"><token type="LiteralStringInterpol"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|]+"><usingself state="root"/></rule>
|
||||
</state>
|
||||
<state name="string-escape">
|
||||
<rule pattern="(\\\\|\\n|\\t|\\")"><token type="LiteralStringEscape"/></rule>
|
||||
</state>
|
||||
<state name="inside-simple-string">
|
||||
<rule><include state="string-escape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="""><token type="LiteralStringDouble"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|"]+"><token type="LiteralString"/></rule>
|
||||
</state>
|
||||
<state name="inside-smart-string">
|
||||
<rule><include state="string-escape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="\n"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|\n]+"><token type="LiteralString"/></rule>
|
||||
</state>
|
||||
<state name="inside-safe-string">
|
||||
<rule><include state="string-escape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="»»»"><token type="LiteralStringDouble"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|»]+"><token type="LiteralString"/></rule>
|
||||
</state>
|
||||
<state name="inside-regex-string">
|
||||
<rule pattern="\\[sSwWdDbBZApPxucItnvfr0]+"><token type="LiteralStringEscape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="\/\}"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|\/]+"><token type="LiteralStringRegex"/></rule>
|
||||
</state>
|
||||
<state name="inside-curly-verb-string">
|
||||
<rule><include state="string-escape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="\:\}"><token type="LiteralStringDouble"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|<:]+"><token type="LiteralString"/></rule>
|
||||
</state>
|
||||
<state name="inside-curly-string">
|
||||
<rule><include state="string-escape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="\}"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|<}]+"><token type="LiteralString"/></rule>
|
||||
</state>
|
||||
<state name="inside-eof-string">
|
||||
<rule><include state="string-escape"/></rule>
|
||||
<rule pattern="\|"><token type="LiteralStringInterpol"/><push state="inside-interpol"/></rule>
|
||||
<rule pattern="\<\|\|"><token type="LiteralStringInterpol"/><push state="inside-template"/></rule>
|
||||
<rule pattern="\Z"><token type="LiteralStringSingle"/><pop depth="1"/></rule>
|
||||
<rule pattern="[^|<]+"><token type="LiteralString"/></rule>
|
||||
</state>
|
||||
<state name="builtin-functions">
|
||||
<rule pattern="\b(all|and|any|ascii|attr|attribute|attributeLabel|binary|blockchar|contains|database|date|dictionary|empty|equal|even|every|exists|false|floatin|function|greater|greaterOrEqual|if|in|inline|integer|is|key|label|leap|less|lessOrEqual|literal|logical|lower|nand|negative|nor|not|notEqual|null|numeric|odd|or|path|pathLabel|positive|prefix|prime|set|some|sorted|standalone|string|subset|suffix|superset|ymbol|true|try|type|unless|upper|when|whitespace|word|xnor|xor|zero)\b\?"><token type="NameBuiltin"/></rule>
|
||||
<rule pattern="\b(abs|acos|acosh|acsec|acsech|actan|actanh|add|after|alphabet|and|angle|append|arg|args|arity|array|as|asec|asech|asin|asinh|atan|atan2|atanh|attr|attrs|average|before|benchmark|blend|break|builtins1|builtins2|call|capitalize|case|ceil|chop|chunk|clear|close|cluster|color|combine|conj|continue|copy|cos|cosh|couple|csec|csech|ctan|ctanh|cursor|darken|dec|decode|decouple|define|delete|desaturate|deviation|dictionary|difference|digest|digits|div|do|download|drop|dup|e|else|empty|encode|ensure|env|epsilon|escape|execute|exit|exp|extend|extract|factors|false|fdiv|filter|first|flatten|floor|fold|from|function|gamma|gcd|get|goto|hash|help|hypot|if|in|inc|indent|index|infinity|info|input|insert|inspect|intersection|invert|join|keys|kurtosis|last|let|levenshtein|lighten|list|ln|log|loop|lower|mail|map|match|max|maybe|median|min|mod|module|mul|nand|neg|new|nor|normalize|not|now|null|open|or|outdent|pad|panic|path|pause|permissions|permutate|pi|pop|pow|powerset|powmod|prefix|print|prints|process|product|query|random|range|read|relative|remove|rename|render|repeat|replace|request|return|reverse|round|sample|saturate|script|sec|sech|select|serve|set|shl|shr|shuffle|sin|sinh|size|skewness|slice|sort|split|sqrt|squeeze|stack|strip|sub|suffix|sum|switch|symbols|symlink|sys|take|tan|tanh|terminal|to|true|truncate|try|type|union|unique|unless|until|unzip|upper|values|var|variance|volume|webview|while|with|wordwrap|write|xnor|xor|zip)\b"><token type="NameBuiltin"/></rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
+1
-1
@@ -81,7 +81,7 @@
|
||||
<rule pattern="[^\S\n]+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern="//.*?\n">
|
||||
<rule pattern="//[^\n]*\n?">
|
||||
<token type="CommentSingle"/>
|
||||
</rule>
|
||||
<rule pattern="/\*.*?\*/">
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<lexer>
|
||||
<config>
|
||||
<name>Gemfile.lock</name>
|
||||
<alias>gemfile-lock</alias>
|
||||
<alias>gemfilelock</alias>
|
||||
<filename>Gemfile.lock</filename>
|
||||
<filename>*.gemfile.lock</filename>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
<rule pattern="^(GIT|PATH|GEM|PLUGIN SOURCE|PLATFORMS|DEPENDENCIES|BUNDLED WITH|RUBY VERSION|CHECKSUMS)$">
|
||||
<token type="Keyword"/>
|
||||
</rule>
|
||||
<rule pattern="^([ \t]+)(remote|revision|ref|branch|tag|submodules|specs|glob)(:)">
|
||||
<bygroups>
|
||||
<token type="Text"/>
|
||||
<token type="NameAttribute"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="\(">
|
||||
<token type="Punctuation"/>
|
||||
<push state="version"/>
|
||||
</rule>
|
||||
<rule pattern="!">
|
||||
<token type="Operator"/>
|
||||
</rule>
|
||||
<rule pattern="https?://\S+">
|
||||
<token type="LiteralStringSymbol"/>
|
||||
</rule>
|
||||
<rule pattern="git@\S+">
|
||||
<token type="LiteralStringSymbol"/>
|
||||
</rule>
|
||||
<rule pattern="sha\d+=[A-Fa-f0-9]+">
|
||||
<token type="LiteralNumberHex"/>
|
||||
</rule>
|
||||
<rule pattern="\b[a-f0-9]{7,40}\b">
|
||||
<token type="LiteralNumberHex"/>
|
||||
</rule>
|
||||
<rule pattern="\b\d[\w.]*">
|
||||
<token type="LiteralNumber"/>
|
||||
</rule>
|
||||
<rule pattern="[A-Za-z_][A-Za-z0-9_.-]*">
|
||||
<token type="Name"/>
|
||||
</rule>
|
||||
<rule pattern="\n">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern="[ \t]+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern=".">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="version">
|
||||
<rule pattern="\)">
|
||||
<token type="Punctuation"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="(~>|>=|<=|!=|=|<|>)">
|
||||
<token type="Operator"/>
|
||||
</rule>
|
||||
<rule pattern="[0-9][\w.]*">
|
||||
<token type="LiteralNumber"/>
|
||||
</rule>
|
||||
<rule pattern="[A-Za-z][\w.-]*">
|
||||
<token type="Name"/>
|
||||
</rule>
|
||||
<rule pattern=",">
|
||||
<token type="Punctuation"/>
|
||||
</rule>
|
||||
<rule pattern="\s+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern=".">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
+16
-3
@@ -32,7 +32,7 @@
|
||||
pattern="(assert|break|case|catch|continue|default|do|else|finally|for|if|goto|instanceof|new|return|switch|this|throw|try|while)\b">
|
||||
<token type="Keyword" />
|
||||
</rule>
|
||||
<rule pattern="((?:(?:[^\W\d]|\$)[\w.\[\]$<>]*\s+)+?)((?:[^\W\d]|\$)[\w$]*)(\s*)(\()">
|
||||
<rule pattern="((?:(?:[^\W\d]|\$)[\w.\[\]$<>?]*\s+)+?)((?:[^\W\d]|\$)[\w$]*)(\s*)(\()">
|
||||
<bygroups>
|
||||
<usingself state="root" />
|
||||
<token type="NameFunction" />
|
||||
@@ -44,7 +44,7 @@
|
||||
<token type="NameDecorator" />
|
||||
</rule>
|
||||
<rule
|
||||
pattern="(abstract|const|enum|extends|final|implements|native|private|protected|public|sealed|static|strictfp|super|synchronized|throws|transient|volatile|yield)\b">
|
||||
pattern="(abstract|const|enum|exports|extends|final|implements|native|non-sealed|open|opens|permits|private|protected|provides|public|requires|sealed|static|strictfp|super|synchronized|throws|to|transient|transitive|uses|volatile|with|yield)\b">
|
||||
<token type="KeywordDeclaration" />
|
||||
</rule>
|
||||
<rule pattern="(boolean|byte|char|double|float|int|long|short|void)\b">
|
||||
@@ -64,6 +64,10 @@
|
||||
<token type="KeywordDeclaration" />
|
||||
<push state="class" />
|
||||
</rule>
|
||||
<rule pattern="(module)\b">
|
||||
<token type="KeywordDeclaration" />
|
||||
<push state="module" />
|
||||
</rule>
|
||||
<rule pattern="(var)(\s+)">
|
||||
<bygroups>
|
||||
<token type="KeywordDeclaration" />
|
||||
@@ -71,7 +75,7 @@
|
||||
</bygroups>
|
||||
<push state="var" />
|
||||
</rule>
|
||||
<rule pattern="(import(?:\s+static)?)(\s+)">
|
||||
<rule pattern="(import(?:\s+(?:static|module))?)(\s+)">
|
||||
<bygroups>
|
||||
<token type="KeywordNamespace" />
|
||||
<token type="TextWhitespace" />
|
||||
@@ -147,6 +151,15 @@
|
||||
<pop depth="1" />
|
||||
</rule>
|
||||
</state>
|
||||
<state name="module">
|
||||
<rule pattern="\s+">
|
||||
<token type="Text" />
|
||||
</rule>
|
||||
<rule pattern="([^\W\d]|\$)[\w$]*">
|
||||
<token type="NameClass" />
|
||||
<pop depth="1" />
|
||||
</rule>
|
||||
</state>
|
||||
<state name="var">
|
||||
<rule pattern="([^\W\d]|\$)[\w$]*">
|
||||
<token type="Name" />
|
||||
|
||||
+3
@@ -2,12 +2,15 @@
|
||||
<config>
|
||||
<name>JSON</name>
|
||||
<alias>json</alias>
|
||||
<alias>jsonl</alias>
|
||||
<filename>*.json</filename>
|
||||
<filename>*.jsonl</filename>
|
||||
<filename>*.jsonc</filename>
|
||||
<filename>*.json5</filename>
|
||||
<filename>*.avsc</filename>
|
||||
<filename>.luaurc</filename>
|
||||
<mime_type>application/json</mime_type>
|
||||
<mime_type>application/jsonl</mime_type>
|
||||
<dot_all>true</dot_all>
|
||||
<not_multiline>true</not_multiline>
|
||||
</config>
|
||||
|
||||
+133
File diff suppressed because one or more lines are too long
+6
-1
@@ -3,7 +3,12 @@
|
||||
<name>Nu</name>
|
||||
<alias>nu</alias>
|
||||
<filename>*.nu</filename>
|
||||
<mime_type>application/x-shellscript</mime_type>
|
||||
<mime_type>text/plain</mime_type>
|
||||
<mime_type>text/x-shellscript</mime_type>
|
||||
<analyse first="true" >
|
||||
<regex pattern="(?m)^#!.*/bin/(?:env(?: -[a-zA-Z0-9]+)*(?: --[a-zA-Z0-9-=]+)* |)nu" score="1.0" />
|
||||
</analyse>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
@@ -118,4 +123,4 @@
|
||||
<rule><include state="root" /></rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
</lexer>
|
||||
+5
-1
@@ -5,6 +5,10 @@
|
||||
<alias>postscr</alias>
|
||||
<filename>*.ps</filename>
|
||||
<filename>*.eps</filename>
|
||||
<filename>*.epsf</filename>
|
||||
<filename>*.epsi</filename>
|
||||
<filename>*.pfa</filename>
|
||||
<filename>*.t42</filename>
|
||||
<mime_type>application/postscript</mime_type>
|
||||
</config>
|
||||
<rules>
|
||||
@@ -86,4 +90,4 @@
|
||||
</rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
</lexer>
|
||||
|
||||
Generated
Vendored
+12
-6
@@ -7,7 +7,7 @@
|
||||
</config>
|
||||
<rules>
|
||||
<state name="package">
|
||||
<rule pattern="[a-zA-Z_]\w*">
|
||||
<rule pattern="[a-zA-Z_][\w.]*">
|
||||
<token type="NameNamespace"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
@@ -16,7 +16,7 @@
|
||||
</rule>
|
||||
</state>
|
||||
<state name="message">
|
||||
<rule pattern="[a-zA-Z_]\w*">
|
||||
<rule pattern="[a-zA-Z_][\w.]*">
|
||||
<token type="NameClass"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
@@ -34,7 +34,7 @@
|
||||
</rule>
|
||||
</state>
|
||||
<state name="root">
|
||||
<rule pattern="[ \t]+">
|
||||
<rule pattern="\s+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern="[,;{}\[\]()<>]">
|
||||
@@ -46,9 +46,12 @@
|
||||
<rule pattern="/(\\\n)?\*(.|\n)*?\*(\\\n)?/">
|
||||
<token type="CommentMultiline"/>
|
||||
</rule>
|
||||
<rule pattern="\b(extensions|required|repeated|optional|returns|default|option|packed|import|ctype|oneof|max|rpc|to)\b">
|
||||
<rule pattern="\b(ctype|default|edition|export|local|max|option|optional|packed|public|repeated|required|reserved|returns|stream|syntax|to|weak)\b">
|
||||
<token type="Keyword"/>
|
||||
</rule>
|
||||
<rule pattern="\b(extensions|map)\b">
|
||||
<token type="KeywordDeclaration"/>
|
||||
</rule>
|
||||
<rule pattern="(sfixed32|sfixed64|fixed32|fixed64|sint32|sint64|double|string|uint32|uint64|int32|float|int64|bytes|bool)\b">
|
||||
<token type="KeywordType"/>
|
||||
</rule>
|
||||
@@ -62,6 +65,9 @@
|
||||
</bygroups>
|
||||
<push state="package"/>
|
||||
</rule>
|
||||
<rule pattern="import\b">
|
||||
<token type="KeywordNamespace"/>
|
||||
</rule>
|
||||
<rule pattern="(message|extend)(\s+)">
|
||||
<bygroups>
|
||||
<token type="KeywordDeclaration"/>
|
||||
@@ -69,7 +75,7 @@
|
||||
</bygroups>
|
||||
<push state="message"/>
|
||||
</rule>
|
||||
<rule pattern="(enum|group|service)(\s+)">
|
||||
<rule pattern="(enum|group|oneof|rpc|service)(\s+)">
|
||||
<bygroups>
|
||||
<token type="KeywordDeclaration"/>
|
||||
<token type="Text"/>
|
||||
@@ -115,4 +121,4 @@
|
||||
</rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
</lexer>
|
||||
|
||||
+4
@@ -12,7 +12,11 @@
|
||||
<filename>*.rbx</filename>
|
||||
<filename>*.duby</filename>
|
||||
<filename>Gemfile</filename>
|
||||
<filename>*.gemfile</filename>
|
||||
<filename>Vagrantfile</filename>
|
||||
<filename>Appraisals</filename>
|
||||
<filename>.pryrc</filename>
|
||||
<filename>*.json.jbuilder</filename>
|
||||
<mime_type>text/x-ruby</mime_type>
|
||||
<mime_type>application/x-ruby</mime_type>
|
||||
<dot_all>true</dot_all>
|
||||
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
<lexer>
|
||||
<config>
|
||||
<name>Templ</name>
|
||||
<alias>templ</alias>
|
||||
<filename>*.templ</filename>
|
||||
<mime_type>text/x-templ</mime_type>
|
||||
<dot_all>true</dot_all>
|
||||
<analyse>
|
||||
<regex pattern="(?m)^\s*templ\s+[A-Za-z_]\w*\s*\(" score="0.7"/>
|
||||
<regex pattern="(?m)^\s*package\s+\w+[\s\S]*^\s*templ\s+" score="0.5"/>
|
||||
</analyse>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
<rule pattern="//[^\n\r]*">
|
||||
<token type="CommentSingle"/>
|
||||
</rule>
|
||||
<rule pattern="/\*(?:.|\n)*?\*/">
|
||||
<token type="CommentMultiline"/>
|
||||
</rule>
|
||||
<rule pattern="(?m)^(\s*)(package|import|const|type|func)([^\n]*)">
|
||||
<bygroups>
|
||||
<token type="TextWhitespace"/>
|
||||
<using lexer="Go"/>
|
||||
<using lexer="Go"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="\b(templ|css|script)(\s+)([A-Za-z_]\w*)(\s*)(\([^{}]*\))(\s*)({)">
|
||||
<bygroups>
|
||||
<token type="KeywordDeclaration"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="NameFunction"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="@[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*(?:\([^{}()\r\n]*(?:\([^{}()\r\n]*\)[^{}()\r\n]*)*\))?(?:[ \t]*{)?">
|
||||
<token type="NameFunction"/>
|
||||
</rule>
|
||||
<rule pattern="(?m)^(\s*)(if|for|switch|select)(\s+)([^{}\n]*)(\s*)({)">
|
||||
<bygroups>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Keyword"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="(?m)^(\s*)(else)(\s*)(if)?(\s*)([^{}\n]*)(\s*)({)?">
|
||||
<bygroups>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Keyword"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Keyword"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="(})(\s*)(else)(\s*)(if)?(\s*)([^{}\n]*)(\s*)({)?">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Keyword"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Keyword"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="({)([^{}\n]*)(})">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="}">
|
||||
<token type="Punctuation"/>
|
||||
</rule>
|
||||
<rule pattern="`(?:.|\n)*?`">
|
||||
<token type="LiteralStringBacktick"/>
|
||||
</rule>
|
||||
<rule pattern="\s+">
|
||||
<token type="TextWhitespace"/>
|
||||
</rule>
|
||||
<rule pattern="[^<&@{}`\s]+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern="&\S*?;">
|
||||
<token type="NameEntity"/>
|
||||
</rule>
|
||||
<rule pattern="\<\!\[CDATA\[.*?\]\]\>">
|
||||
<token type="CommentPreproc"/>
|
||||
</rule>
|
||||
<rule pattern="<!--">
|
||||
<token type="Comment"/>
|
||||
<push state="comment"/>
|
||||
</rule>
|
||||
<rule pattern="<\?.*?\?>">
|
||||
<token type="CommentPreproc"/>
|
||||
</rule>
|
||||
<rule pattern="<![^>]*>">
|
||||
<token type="CommentPreproc"/>
|
||||
</rule>
|
||||
<rule pattern="(<)(script)(\s*)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="NameTag"/>
|
||||
<token type="Text"/>
|
||||
</bygroups>
|
||||
<push state="script-content" state="tag"/>
|
||||
</rule>
|
||||
<rule pattern="(<)(style)(\s*)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="NameTag"/>
|
||||
<token type="Text"/>
|
||||
</bygroups>
|
||||
<push state="style-content" state="tag"/>
|
||||
</rule>
|
||||
<rule pattern="(<)([A-Za-z][\w:.-]*)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="NameTag"/>
|
||||
</bygroups>
|
||||
<push state="tag"/>
|
||||
</rule>
|
||||
<rule pattern="(</)([A-Za-z][\w:.-]*)(\s*)(>)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="NameTag"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="[@{}<&]">
|
||||
<token type="Punctuation"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="script-content">
|
||||
<rule pattern="(<)(\s*)(/)(\s*)(script)(\s*)(>)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Text"/>
|
||||
<token type="NameTag"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="\s*[^\r\n]*\{\{.*?\}\}[^\r\n]*">
|
||||
<token type="Other"/>
|
||||
</rule>
|
||||
<rule pattern=".+?(?=<\s*/\s*script\s*>)">
|
||||
<token type="Other"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="style-content">
|
||||
<rule pattern="(<)(\s*)(/)(\s*)(style)(\s*)(>)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Text"/>
|
||||
<token type="NameTag"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern=".+?(?=<\s*/\s*style\s*>)">
|
||||
<using lexer="CSS"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="comment">
|
||||
<rule pattern="[^-]+">
|
||||
<token type="Comment"/>
|
||||
</rule>
|
||||
<rule pattern="-->">
|
||||
<token type="Comment"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="-">
|
||||
<token type="Comment"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="tag">
|
||||
<rule pattern="\s+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern="//[^\n\r]*">
|
||||
<token type="CommentSingle"/>
|
||||
</rule>
|
||||
<rule pattern="/\*(?:.|\n)*?\*/">
|
||||
<token type="CommentMultiline"/>
|
||||
</rule>
|
||||
<rule pattern="\b(if|for|switch|select)(\s+)([^{}]*)(\s*)({)">
|
||||
<bygroups>
|
||||
<token type="Keyword"/>
|
||||
<token type="Text"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="\b(else)(\s*)(if)?(\s*)([^{}]*)(\s*)({)?">
|
||||
<bygroups>
|
||||
<token type="Keyword"/>
|
||||
<token type="Text"/>
|
||||
<token type="Keyword"/>
|
||||
<token type="Text"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="}">
|
||||
<token type="Punctuation"/>
|
||||
</rule>
|
||||
<rule pattern="({)([^{}]*)(})(\??)(\s*)(=)(\s*)({)([^{}]*)(})">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Operator"/>
|
||||
<token type="Text"/>
|
||||
<token type="Operator"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="({)([^{}]*)(})(\??)(\s*)(=)(\s*)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Operator"/>
|
||||
<token type="Text"/>
|
||||
<token type="Operator"/>
|
||||
<token type="Text"/>
|
||||
</bygroups>
|
||||
<push state="attr"/>
|
||||
</rule>
|
||||
<rule pattern="({)([^{}]*)(})(\??)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Operator"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="({)([^{}]*)(})">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="((?:[@#.][\w:.*-]+|\[[^\]\s=]+\]|[\w:.*-]+\??)\s*)(=)(\s*)({)([^{}]*)(})">
|
||||
<bygroups>
|
||||
<token type="NameAttribute"/>
|
||||
<token type="Operator"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
<using lexer="Go"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="((?:[@#.][\w:.*-]+|\[[^\]\s=]+\]|[\w:.*-]+\??)\s*)(=)(\s*)">
|
||||
<bygroups>
|
||||
<token type="NameAttribute"/>
|
||||
<token type="Operator"/>
|
||||
<token type="Text"/>
|
||||
</bygroups>
|
||||
<push state="attr"/>
|
||||
</rule>
|
||||
<rule pattern="(?:[@#.][\w:.*-]+|\[[^\]\s=]+\]|[\w:.*-]+\??)">
|
||||
<token type="NameAttribute"/>
|
||||
</rule>
|
||||
<rule pattern="(/?)(\s*)(>)">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="Text"/>
|
||||
<token type="Punctuation"/>
|
||||
</bygroups>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="attr">
|
||||
<rule pattern="".*?"">
|
||||
<token type="LiteralString"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="'.*?'">
|
||||
<token type="LiteralString"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="[^\s>]+">
|
||||
<token type="LiteralString"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
+14
-4
@@ -23,6 +23,9 @@
|
||||
<rule pattern="#.*$">
|
||||
<token type="Comment"/>
|
||||
</rule>
|
||||
<rule pattern="//.*$">
|
||||
<token type="Comment"/>
|
||||
</rule>
|
||||
<rule pattern="!![^\s]+">
|
||||
<token type="CommentPreproc"/>
|
||||
</rule>
|
||||
@@ -78,15 +81,22 @@
|
||||
<token type="Comment"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="([^\{\}\[\]\?,\:\!\-\*&\@].*)( )+(//.*)">
|
||||
<bygroups>
|
||||
<token type="Literal"/>
|
||||
<token type="TextWhitespace"/>
|
||||
<token type="Comment"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="[^\{\}\[\]\?,\:\!\-\*&\@].*">
|
||||
<token type="Literal"/>
|
||||
</rule>
|
||||
</state>
|
||||
<state name="key">
|
||||
<rule pattern=""[^"\n].*": ">
|
||||
<rule pattern=""[^"\n#].*": ">
|
||||
<token type="NameTag"/>
|
||||
</rule>
|
||||
<rule pattern="(-)( )([^"\n{]*)(:)( )">
|
||||
<rule pattern="(-)( )((?:(?!//)[^"\n{#])*?)(:)( )">
|
||||
<bygroups>
|
||||
<token type="Punctuation"/>
|
||||
<token type="TextWhitespace"/>
|
||||
@@ -95,14 +105,14 @@
|
||||
<token type="TextWhitespace"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="([^"\n{]*)(:)( )">
|
||||
<rule pattern="((?:(?!//)[^"\n{#])*?)(:)( )">
|
||||
<bygroups>
|
||||
<token type="NameTag"/>
|
||||
<token type="Punctuation"/>
|
||||
<token type="TextWhitespace"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="([^"\n{]*)(:)(\n)">
|
||||
<rule pattern="((?:(?!//)[^"\n{#])*?)(:)(\n)">
|
||||
<bygroups>
|
||||
<token type="NameTag"/>
|
||||
<token type="Punctuation"/>
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ func goRules() Rules {
|
||||
"root": {
|
||||
{`\n`, TextWhitespace, nil},
|
||||
{`\s+`, TextWhitespace, nil},
|
||||
{`//[^\s][^\n\r]*`, CommentPreproc, nil},
|
||||
{`//\s+[^\n\r]*`, CommentSingle, nil},
|
||||
{`//[^\s\n\r][^\n\r]*`, CommentPreproc, nil},
|
||||
{`//[^\n\r]*`, CommentSingle, nil},
|
||||
{`/(\\\n)?[*](.|\n)*?[*](\\\n)?/`, CommentMultiline, nil},
|
||||
{`(import|package)\b`, KeywordNamespace, nil},
|
||||
{`(var|func|struct|map|chan|type|interface|const)\b`, KeywordDeclaration, nil},
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ func (d *httpBodyContentTyper) Tokenise(options *TokeniseOptions, text string) (
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return EOF
|
||||
return subIterator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+64
-3
@@ -1,11 +1,13 @@
|
||||
package lexers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "github.com/alecthomas/chroma/v2" // nolint
|
||||
)
|
||||
|
||||
// Markdown lexer.
|
||||
var Markdown = Register(MustNewLexer(
|
||||
// Markdown lexer with YAML frontmatter and HTML comment support.
|
||||
var Markdown = Register(&markdownLexer{Lexer: MustNewLexer(
|
||||
&Config{
|
||||
Name: "markdown",
|
||||
Aliases: []string{"md", "mkd"},
|
||||
@@ -13,11 +15,69 @@ var Markdown = Register(MustNewLexer(
|
||||
MimeTypes: []string{"text/x-markdown"},
|
||||
},
|
||||
markdownRules,
|
||||
))
|
||||
)})
|
||||
|
||||
// markdownLexer wraps the base Markdown lexer to highlight top-of-file YAML frontmatter.
|
||||
type markdownLexer struct {
|
||||
Lexer
|
||||
}
|
||||
|
||||
// Lexes Markdown, highlighting a leading YAML frontmatter block before delegating to Markdown rules.
|
||||
func (m *markdownLexer) Tokenise(options *TokeniseOptions, text string) (Iterator, error) {
|
||||
frontmatter, rest, ok := splitFrontmatter(text)
|
||||
if !ok {
|
||||
return m.Lexer.Tokenise(options, text)
|
||||
}
|
||||
|
||||
yamlLexer := Get("YAML")
|
||||
if yamlLexer == nil {
|
||||
return m.Lexer.Tokenise(options, text)
|
||||
}
|
||||
|
||||
yamlTokens, err := yamlLexer.Tokenise(options, frontmatter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
markdownTokens, err := m.Lexer.Tokenise(options, rest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Concaterator(yamlTokens, markdownTokens), nil
|
||||
}
|
||||
|
||||
// Extracts a leading YAML frontmatter block if the document starts with one.
|
||||
func splitFrontmatter(text string) (frontmatter string, rest string, ok bool) {
|
||||
if !strings.HasPrefix(text, "---\n") && !strings.HasPrefix(text, "---\r\n") {
|
||||
return "", text, false
|
||||
}
|
||||
|
||||
lineEnd := strings.IndexByte(text, '\n')
|
||||
if lineEnd < 0 {
|
||||
return "", text, false
|
||||
}
|
||||
if strings.TrimSuffix(text[:lineEnd], "\r") != "---" {
|
||||
return "", text, false
|
||||
}
|
||||
|
||||
for pos := lineEnd + 1; pos < len(text); {
|
||||
next := strings.IndexByte(text[pos:], '\n')
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
lineEnd = pos + next
|
||||
line := strings.TrimSuffix(text[pos:lineEnd], "\r")
|
||||
if line == "---" {
|
||||
return text[:lineEnd+1], text[lineEnd+1:], true
|
||||
}
|
||||
pos = lineEnd + 1
|
||||
}
|
||||
return "", text, false
|
||||
}
|
||||
|
||||
func markdownRules() Rules {
|
||||
return Rules{
|
||||
"root": {
|
||||
{`<!--[\w\W]*?-->`, CommentMultiline, nil},
|
||||
{`^(#[^#].+\n)`, ByGroups(GenericHeading), nil},
|
||||
{`^(#{2,6}.+\n)`, ByGroups(GenericSubheading), nil},
|
||||
{`^(\s*)([*-] )(\[[ xX]\])( .+\n)`, ByGroups(Text, Keyword, Keyword, UsingSelf("inline")), nil},
|
||||
@@ -33,6 +93,7 @@ func markdownRules() Rules {
|
||||
Include("inline"),
|
||||
},
|
||||
"inline": {
|
||||
{`<!--[\w\W]*?-->`, CommentMultiline, nil},
|
||||
{`\\.`, Text, nil},
|
||||
{`(\s)(\*|_)((?:(?!\2).)*)(\2)((?=\W|\n))`, ByGroups(Text, GenericEmph, GenericEmph, GenericEmph, Text), nil},
|
||||
{`(\s)((\*\*|__).*?)\3((?=\W|\n))`, ByGroups(Text, GenericStrong, GenericStrong, Text), nil},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user