Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
556e0a70d5 | ||
|
|
0e20861419 | ||
|
|
8224626ed4 | ||
|
|
45c17a913f | ||
|
|
f3a7dea1f1 | ||
|
|
c0636f204e | ||
|
|
c418f8ec00 | ||
|
|
e9ad5dcc09 | ||
|
|
d072f34a67 | ||
|
|
a4c5e17bbb | ||
|
|
8d9290ea1f | ||
|
|
07d89202b4 | ||
|
|
5d6599237d | ||
|
|
b13b737461 | ||
|
|
c9ba8520c7 | ||
|
|
ee782785f0 | ||
|
|
3b1d7be6fc | ||
|
|
e98657a88a | ||
|
|
a96bddcb8d | ||
|
|
5ff5059db3 | ||
|
|
ff85bbc5ea | ||
|
|
5d88c1b2fa | ||
|
|
114a1be2e9 | ||
|
|
59c92f8c4d | ||
|
|
ddcb2c7b5f | ||
|
|
f8ba38779b | ||
|
|
94d1f6e224 | ||
|
|
ba3df171d1 | ||
|
|
7ec5af8068 | ||
|
|
fc6f15534e | ||
|
|
80c12a3f10 | ||
|
|
bea67f0136 | ||
|
|
8b9e5c6c8c | ||
|
|
06436ff9b0 | ||
|
|
b888a53759 | ||
|
|
c26f45e55e | ||
|
|
ba7c3aed94 | ||
|
|
8aafc8fcd7 | ||
|
|
ab9e6675cf | ||
|
|
ed67390dea | ||
|
|
aca0cff399 | ||
|
|
f359256489 | ||
|
|
336f17e7b7 | ||
|
|
0105bc2314 | ||
|
|
78d6de6bce | ||
|
|
638fa63e81 | ||
|
|
e3b0d63648 | ||
|
|
b38d91cb0f | ||
|
|
d6d53ff2e0 | ||
|
|
195becc6ee | ||
|
|
5bc5167a31 | ||
|
|
1320b1f055 | ||
|
|
283e2c19c7 | ||
|
|
55d9e22373 | ||
|
|
31e20fc26d | ||
|
|
c2cc2305c1 | ||
|
|
4d0bd29b45 | ||
|
|
a1215d87d9 | ||
|
|
3c2679a1d5 | ||
|
|
79177e52f9 | ||
|
|
baa615a0d1 | ||
|
|
e665dcf9e2 | ||
|
|
b7a13a6636 | ||
|
|
218570cfad | ||
|
|
4c9a791d6d | ||
|
|
cdad560df3 |
@@ -60,13 +60,13 @@ jobs:
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: "v2.6.0"
|
||||
version: "v2.10.1"
|
||||
working-directory: backend/app
|
||||
|
||||
- name: golangci-lint on example directory
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: "v2.6.0"
|
||||
version: "v2.10.1"
|
||||
args: --config ../../.golangci.yml
|
||||
working-directory: backend/_example/memory_store
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: expose GitHub Actions cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
|
||||
@@ -30,29 +30,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -77,29 +66,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -124,29 +102,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -157,7 +124,7 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
working-directory: ./frontend
|
||||
|
||||
@@ -30,29 +30,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -77,29 +66,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -124,29 +102,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -172,8 +139,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
@@ -200,29 +166,18 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4.2.0
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ env.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -233,7 +188,7 @@ jobs:
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
name: site
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
@@ -43,10 +44,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -54,7 +55,7 @@ jobs:
|
||||
|
||||
- name: build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./site
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -69,7 +70,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: upload digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: site-digests-${{ matrix.artifact }}
|
||||
path: /tmp/digests/*
|
||||
@@ -85,7 +86,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: site-digests-*
|
||||
@@ -103,10 +104,10 @@ jobs:
|
||||
echo "All $expected digests present"
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -133,7 +134,8 @@ jobs:
|
||||
name: Deploy site
|
||||
runs-on: ubuntu-latest
|
||||
needs: merge
|
||||
if: github.ref == 'refs/heads/master'
|
||||
if: github.ref == 'refs/heads/master' || github.event_name == 'release'
|
||||
permissions: {} # only calls an external URL via curl, no GitHub API access needed
|
||||
|
||||
steps:
|
||||
- name: trigger deployment
|
||||
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PKG_TOKEN }}
|
||||
|
||||
- name: login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: umputun
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
- name: build and push to ghcr.io by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
|
||||
- name: build and push to DockerHub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
@@ -104,14 +104,14 @@ jobs:
|
||||
touch "/tmp/digests/dockerhub/${digest_dockerhub#sha256:}"
|
||||
|
||||
- name: upload ghcr digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-ghcr-${{ matrix.artifact }}
|
||||
path: /tmp/digests/ghcr/*
|
||||
retention-days: 1
|
||||
|
||||
- name: upload dockerhub digest
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-dockerhub-${{ matrix.artifact }}
|
||||
path: /tmp/digests/dockerhub/*
|
||||
@@ -127,14 +127,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download ghcr digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests/ghcr
|
||||
pattern: digests-ghcr-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: download dockerhub digests
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests/dockerhub
|
||||
pattern: digests-dockerhub-*
|
||||
@@ -154,17 +154,17 @@ jobs:
|
||||
echo "All digests present for both registries"
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PKG_TOKEN }}
|
||||
|
||||
- name: login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: umputun
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
@@ -206,6 +206,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'master'
|
||||
permissions: {} # only calls an external URL via curl, no GitHub API access needed
|
||||
|
||||
steps:
|
||||
- name: trigger deployment
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
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@v5
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/release.yml"
|
||||
- ".goreleaser.yml"
|
||||
- "Makefile"
|
||||
- "scripts/**"
|
||||
- "backend/**"
|
||||
- "frontend/**"
|
||||
- "README.md"
|
||||
- "LICENSE"
|
||||
- "CLAUDE.md"
|
||||
- "site/src/docs/getting-started/installation/index.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- 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: 16
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: test and build backend
|
||||
run: |
|
||||
go test -race -timeout=120s ./...
|
||||
go build -race ./...
|
||||
working-directory: backend/app
|
||||
env:
|
||||
TZ: "America/Chicago"
|
||||
|
||||
- name: test examples
|
||||
run: |
|
||||
go test -race ./...
|
||||
go build -race ./...
|
||||
working-directory: backend/_example/memory_store
|
||||
env:
|
||||
TZ: "America/Chicago"
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
- name: check frontend
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm type-check
|
||||
pnpm test -- --runInBand
|
||||
working-directory: frontend/apps/remark42
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
- name: check goreleaser snapshot
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: latest
|
||||
args: release --snapshot --clean --skip=publish
|
||||
env:
|
||||
SKIP_PNPM_INSTALL: "true"
|
||||
|
||||
- name: clean generated release assets
|
||||
if: always()
|
||||
run: ./scripts/cleanup-release-assets.sh
|
||||
|
||||
release:
|
||||
if: github.event_name == 'push'
|
||||
needs: validate
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- 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: 16
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
|
||||
- name: install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
- name: run goreleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SKIP_PNPM_INSTALL: "true"
|
||||
|
||||
- name: clean generated release assets
|
||||
if: always()
|
||||
run: ./scripts/cleanup-release-assets.sh
|
||||
@@ -15,6 +15,7 @@ debug.test
|
||||
.mongo
|
||||
remark42
|
||||
/bin/
|
||||
/dist/
|
||||
/backend/var/
|
||||
/backend/app/var/
|
||||
/backend/app/cmd/web/
|
||||
@@ -26,3 +27,6 @@ compose-private.yml
|
||||
http-client.env.json
|
||||
/playwright-report/
|
||||
/backend/app/cmd/var
|
||||
|
||||
# ralphex progress logs
|
||||
.ralphex/progress/
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
version: 2
|
||||
|
||||
project_name: remark42
|
||||
|
||||
git:
|
||||
ignore_tags:
|
||||
- backend/*
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- ./scripts/prepare-release-assets.sh
|
||||
|
||||
builds:
|
||||
- id: remark42
|
||||
dir: backend
|
||||
main: ./app
|
||||
binary: "remark42.{{ .Os }}-{{ .Arch }}"
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- freebsd
|
||||
- windows
|
||||
goarch:
|
||||
- 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" }}
|
||||
|
||||
archives:
|
||||
- id: remark42
|
||||
ids:
|
||||
- remark42
|
||||
name_template: "{{ .ProjectName }}.{{ .Os }}-{{ .Arch }}"
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
files:
|
||||
- LICENSE
|
||||
- README.md
|
||||
|
||||
release:
|
||||
name_template: "Version {{ .Version }}"
|
||||
mode: keep-existing
|
||||
@@ -17,12 +17,35 @@
|
||||
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
|
||||
- Frontend: `cd frontend && 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.
|
||||
|
||||
## Release Procedure
|
||||
|
||||
Remark42 uses two tags for each release:
|
||||
- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.
|
||||
- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.
|
||||
|
||||
Release flow:
|
||||
1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.
|
||||
2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.
|
||||
3. Create and push the matching backend module tag pointing at the same commit:
|
||||
|
||||
```bash
|
||||
git fetch origin --tags
|
||||
git tag backend/vX.Y.Z vX.Y.Z
|
||||
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.
|
||||
|
||||
## Code Style
|
||||
- **Backend**: Formatting with golangci-lint, strict error handling
|
||||
- **Frontend**: TypeScript with ESLint, Stylelint and Prettier
|
||||
- **Imports**: Group stdlib, external packages, then internal packages
|
||||
- **CSS**: CSS Modules for new components (`component.module.css`)
|
||||
- **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`
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
FROM node:16-alpine AS frontend-deps
|
||||
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /srv/frontend
|
||||
|
||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/
|
||||
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/package.json
|
||||
|
||||
RUN apk add --no-cache --update git && npm i -g pnpm@8
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 pnpm i
|
||||
|
||||
FROM frontend-deps AS build-frontend
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /srv/frontend/apps/remark42/
|
||||
COPY ./frontend/apps/remark42/ /srv/frontend/apps/remark42/
|
||||
RUN pnpm build
|
||||
|
||||
FROM umputun/baseimage:buildgo-v1.14.0 AS build-backend
|
||||
|
||||
ARG GITHUB_TOKEN
|
||||
ARG GITHUB_REF
|
||||
ARG GITHUB_SHA
|
||||
|
||||
WORKDIR /build/backend
|
||||
ADD backend /build/backend
|
||||
ADD README.md /build/
|
||||
ADD LICENSE /build/
|
||||
|
||||
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" {} \;
|
||||
|
||||
RUN \
|
||||
version=$("/script/version.sh") && echo "version=${version}" && \
|
||||
GOOS=linux GOARCH=amd64 go build -o remark42.linux-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=linux GOARCH=386 go build -o remark42.linux-386 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=linux GOARCH=arm go build -o remark42.linux-arm -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=linux GOARCH=arm64 go build -o remark42.linux-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=windows GOARCH=amd64 go build -o remark42.windows-amd64.exe -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=darwin GOARCH=amd64 go build -o remark42.darwin-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=darwin GOARCH=arm64 go build -o remark42.darwin-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=freebsd GOARCH=amd64 go build -o remark42.freebsd-amd64 -ldflags "-X main.revision=${version} -s -w" ./app
|
||||
|
||||
RUN \
|
||||
apk add --no-cache --update zip && \
|
||||
cp ../LICENSE ./LICENSE && cp ../README.md ./README.md && \
|
||||
tar cvzf remark42.linux-amd64.tar.gz remark42.linux-amd64 LICENSE README.md && \
|
||||
tar cvzf remark42.linux-386.tar.gz remark42.linux-386 LICENSE README.md && \
|
||||
tar cvzf remark42.linux-arm.tar.gz remark42.linux-arm LICENSE README.md && \
|
||||
tar cvzf remark42.linux-arm64.tar.gz remark42.linux-arm64 LICENSE README.md && \
|
||||
tar cvzf remark42.darwin-amd64.tar.gz remark42.darwin-amd64 LICENSE README.md && \
|
||||
tar cvzf remark42.darwin-arm64.tar.gz remark42.darwin-arm64 LICENSE README.md && \
|
||||
tar cvzf remark42.freebsd-amd64.tar.gz remark42.freebsd-amd64 LICENSE README.md && \
|
||||
zip remark42.windows-amd64.zip remark42.windows-amd64.exe LICENSE README.md
|
||||
|
||||
|
||||
FROM alpine
|
||||
COPY --from=build-backend /build/backend/remark42.* /artifacts/
|
||||
RUN ls -la /artifacts/*
|
||||
CMD ["sleep", "100"]
|
||||
@@ -2,13 +2,13 @@ OS=linux
|
||||
ARCH=amd64
|
||||
GITHUB_REF=$(shell git rev-parse --symbolic-full-name HEAD)
|
||||
GITHUB_SHA=$(shell git rev-parse --short HEAD)
|
||||
CLEANUP_RELEASE_ASSETS=$(CURDIR)/scripts/cleanup-release-assets.sh
|
||||
|
||||
bin:
|
||||
docker build -f Dockerfile.artifacts -t remark42.bin .
|
||||
- @docker rm -f remark42.bin 2>/dev/null || exit 0
|
||||
docker run -d --name=remark42.bin remark42.bin
|
||||
docker cp remark42.bin:/artifacts/remark42.$(OS)-$(ARCH) remark42
|
||||
docker rm -f remark42.bin
|
||||
@set -e; \
|
||||
./scripts/prepare-release-assets.sh; \
|
||||
trap '$(CLEANUP_RELEASE_ASSETS)' EXIT; \
|
||||
cd backend && CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ../remark42 -ldflags "-X main.revision=$(GITHUB_REF)-$(GITHUB_SHA) -s -w" ./app
|
||||
|
||||
docker:
|
||||
DOCKER_BUILDKIT=1 docker build -t umputun/remark42 -t ghcr.io/umputun/remark42 --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) \
|
||||
@@ -21,19 +21,9 @@ dockerx:
|
||||
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
|
||||
|
||||
release:
|
||||
docker build -f Dockerfile.artifacts --no-cache --pull --build-arg CI=true \
|
||||
--build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) -t remark42.bin .
|
||||
- @docker rm -f remark42.bin 2>/dev/null || exit 0
|
||||
- @mkdir -p bin
|
||||
docker run -d --name=remark42.bin remark42.bin
|
||||
docker cp remark42.bin:/artifacts/remark42.linux-amd64.tar.gz bin/remark42.linux-amd64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.linux-386.tar.gz bin/remark42.linux-386.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.linux-arm64.tar.gz bin/remark42.linux-arm64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.darwin-amd64.tar.gz bin/remark42.darwin-amd64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.darwin-arm64.tar.gz bin/remark42.darwin-arm64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.freebsd-amd64.tar.gz bin/remark42.freebsd-amd64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.windows-amd64.zip bin/remark42.windows-amd64.zip
|
||||
docker rm -f remark42.bin
|
||||
@set -e; \
|
||||
trap '$(CLEANUP_RELEASE_ASSETS)' EXIT; \
|
||||
goreleaser release --snapshot --clean --skip=publish
|
||||
|
||||
race_test:
|
||||
cd backend/app && go test -race -timeout=60s -count 1 ./...
|
||||
@@ -52,4 +42,4 @@ rundev:
|
||||
e2e:
|
||||
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
.PHONY: bin backend
|
||||
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments.
|
||||
|
||||
* Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord and Telegram
|
||||
* Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord, Telegram and custom OAuth2 providers
|
||||
* Login via email
|
||||
* Optional anonymous access
|
||||
* Multi-level nested comments with both tree and plain presentations
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ We release patches for security vulnerabilities.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report (suspected) security vulnerabilities to umputun@gmail.com. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
|
||||
Please report (suspected) security vulnerabilities either by using GitHub's [private vulnerability reporting](https://github.com/umputun/remark42/security/advisories/new) (click the "Report a vulnerability" button on the [Security tab](https://github.com/umputun/remark42/security)) or by emailing umputun@gmail.com. You will receive a response within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
|
||||
|
||||
@@ -23,6 +23,9 @@ linters:
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
gosec:
|
||||
excludes:
|
||||
- G117 # false positive: struct field name matches "secret" pattern
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- wrapperFunc
|
||||
@@ -51,6 +54,9 @@ linters:
|
||||
- linters:
|
||||
- revive
|
||||
text: 'var-naming: avoid meaningless package names'
|
||||
- linters:
|
||||
- revive
|
||||
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||
- linters:
|
||||
- dupl
|
||||
- gosec
|
||||
|
||||
@@ -251,11 +251,11 @@ func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
|
||||
|
||||
// ListFlags get list of flagged keys, like blocked & verified user
|
||||
// works for full locator (post flags) or with userID
|
||||
func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err error) {
|
||||
func (m *MemData) ListFlags(req engine.FlagRequest) (res []any, err error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
res = []interface{}{}
|
||||
res = []any{}
|
||||
|
||||
switch req.Flag {
|
||||
case engine.Verified:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -198,7 +199,7 @@ func TestMemData_FindForUserPagination(t *testing.T) {
|
||||
}
|
||||
|
||||
// write 200 comments
|
||||
for i := 0; i < 200; i++ {
|
||||
for i := range 200 {
|
||||
c.ID = fmt.Sprintf("idd-%d", i)
|
||||
c.Text = fmt.Sprintf("text #%d", i)
|
||||
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
|
||||
@@ -484,7 +485,7 @@ func TestMemData_FlagVerified(t *testing.T) {
|
||||
func TestMemData_FlagListVerified(t *testing.T) {
|
||||
|
||||
b := prepMem(t)
|
||||
toIDs := func(inp []interface{}) (res []string) {
|
||||
toIDs := func(inp []any) (res []string) {
|
||||
res = make([]string, len(inp))
|
||||
for i, v := range inp {
|
||||
vv, ok := v.(string)
|
||||
@@ -521,51 +522,52 @@ func TestMemData_FlagListVerified(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMemData_FlagListBlocked(t *testing.T) {
|
||||
|
||||
b := prepMem(t)
|
||||
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
|
||||
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
|
||||
TTL: ttl}
|
||||
_, err := b.Flag(req)
|
||||
return err
|
||||
}
|
||||
|
||||
toBlocked := func(inp []interface{}) (res []store.BlockedUser) {
|
||||
res = make([]store.BlockedUser, len(inp))
|
||||
for i, v := range inp {
|
||||
vv, ok := v.(store.BlockedUser)
|
||||
require.True(t, ok)
|
||||
res[i] = vv
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
b := prepMem(t)
|
||||
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
|
||||
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
|
||||
TTL: ttl}
|
||||
_, err := b.Flag(req)
|
||||
return err
|
||||
}
|
||||
return res
|
||||
}
|
||||
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
|
||||
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
|
||||
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
|
||||
|
||||
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
toBlocked := func(inp []any) (res []store.BlockedUser) {
|
||||
res = make([]store.BlockedUser, len(inp))
|
||||
for i, v := range inp {
|
||||
vv, ok := v.(store.BlockedUser)
|
||||
require.True(t, ok)
|
||||
res[i] = vv
|
||||
}
|
||||
return res
|
||||
}
|
||||
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
|
||||
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
|
||||
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
|
||||
|
||||
blockedList := toBlocked(vv)
|
||||
var blockedIDs = make([]string, len(blockedList))
|
||||
for i, x := range blockedList {
|
||||
blockedIDs[i] = x.ID
|
||||
}
|
||||
require.Equal(t, 2, len(blockedList), b.metaUsers)
|
||||
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIDs)
|
||||
t.Logf("%+v", blockedList)
|
||||
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// check block expiration
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
blockedList = toBlocked(vv)
|
||||
require.Equal(t, 1, len(blockedList))
|
||||
assert.Equal(t, "user1", blockedList[0].ID)
|
||||
blockedList := toBlocked(vv)
|
||||
var blockedIDs = make([]string, len(blockedList))
|
||||
for i, x := range blockedList {
|
||||
blockedIDs[i] = x.ID
|
||||
}
|
||||
require.Equal(t, 2, len(blockedList), b.metaUsers)
|
||||
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIDs)
|
||||
t.Logf("%+v", blockedList)
|
||||
|
||||
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(vv))
|
||||
// check block expiration
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
blockedList = toBlocked(vv)
|
||||
require.Equal(t, 1, len(blockedList))
|
||||
assert.Equal(t, "user1", blockedList[0].ID)
|
||||
|
||||
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(vv))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMemData_DeleteComment(t *testing.T) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
module github.com/umputun/remark42/memory_store
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-pkgz/jrpc v0.4.0
|
||||
github.com/go-pkgz/lgr v0.12.1
|
||||
github.com/go-pkgz/lgr v0.12.3
|
||||
github.com/jessevdk/go-flags v1.6.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/umputun/remark42/backend v1.1000.0
|
||||
@@ -12,13 +12,13 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 // 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/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/go-pkgz/rest v1.20.6 // 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/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
@@ -30,10 +30,10 @@ require (
|
||||
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.46.0 // indirect
|
||||
golang.org/x/image v0.34.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // 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
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8=
|
||||
github.com/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
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.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
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/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=
|
||||
@@ -15,14 +15,14 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3
|
||||
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.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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.1 h1:8GVfG2rSARq3Eaj5PP158rtBR2LHVGkwioIkQBGbvKg=
|
||||
github.com/go-pkgz/lgr v0.12.1/go.mod h1:A4AxjOthFVFK6jRnVYMeusno5SeDAxcLVHd0kI/lN/Y=
|
||||
github.com/go-pkgz/rest v1.20.6 h1:O/IhQ3I2cS4bJYvL1TLcy63w2OcXZTTBG3R+wTIqPS4=
|
||||
github.com/go-pkgz/rest v1.20.6/go.mod h1:NY+MX1is2kJckJt+nHDNovS/5j9jmF4yQuSno4qg7XU=
|
||||
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=
|
||||
@@ -62,10 +62,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
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=
|
||||
@@ -80,8 +80,8 @@ 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
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=
|
||||
@@ -89,8 +89,8 @@ 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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=
|
||||
@@ -102,8 +102,8 @@ 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *RPC) admEnabledHndl(id uint64, params json.RawMessage) (rr jrpc.Respons
|
||||
// onEvent returns nothing, callback to OnEvent
|
||||
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
var ps []interface{}
|
||||
var ps []any
|
||||
if err := json.Unmarshal(params, &ps); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ func TestRPC_listFlagsHndl(t *testing.T) {
|
||||
|
||||
flags, err = re.ListFlags(verifyFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []interface{}{"u1"}, flags)
|
||||
assert.Equal(t, []any{"u1"}, flags)
|
||||
verifiedUsers := make([]string, 0, len(flags))
|
||||
for _, v := range flags {
|
||||
verifiedUsers = append(verifiedUsers, v.(string))
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
)
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
@@ -34,7 +34,7 @@ func chooseRandomUnusedPort() (port int) {
|
||||
func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 3 seconds for server to start before returning it
|
||||
client := http.Client{Timeout: time.Second}
|
||||
for i := 0; i < 300; i++ {
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -47,7 +47,7 @@ func (ec *BackupCommand) Execute(_ []string) error {
|
||||
req.SetBasicAuth("admin", ec.AdminPasswd)
|
||||
|
||||
// get with timeout
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // exportURL is built from operator-supplied CLI flags, not user input
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed for %s: %w", exportURL, err)
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
|
||||
|
||||
commentsWithInfo := struct {
|
||||
Comments []store.Comment `json:"comments"`
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
Info store.PostInfo `json:"info"`
|
||||
}{}
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil {
|
||||
@@ -199,7 +199,7 @@ func (cc *CleanupCommand) deleteComment(c store.Comment) error { //nolint:dupl /
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
r, err := client.Do(req)
|
||||
r, err := client.Do(req) //nolint:gosec // RemarkURL comes from operator CLI flag, not user input
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func (cc *CleanupCommand) setTitle(c store.Comment) error { //nolint:dupl // not
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
r, err := client.Do(req)
|
||||
r, err := client.Do(req) //nolint:gosec // RemarkURL comes from operator CLI flag, not user input
|
||||
if err != nil {
|
||||
return fmt.Errorf("title request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
|
||||
commentsWithInfo := struct {
|
||||
Comments []store.Comment `json:"comments"`
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
Info store.PostInfo `json:"info"`
|
||||
}{}
|
||||
|
||||
switch r.URL.Query().Get("url") {
|
||||
|
||||
@@ -124,7 +124,7 @@ func responseError(resp *http.Response) error {
|
||||
// mkdir -p for all dirs
|
||||
func makeDirs(dirs ...string) error {
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil { // If path is already a directory, MkdirAll does nothing
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil { // if path is already a directory, MkdirAll does nothing
|
||||
return fmt.Errorf("can't make directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func (ic *ImportCommand) Execute(_ []string) error {
|
||||
}
|
||||
req.SetBasicAuth("admin", ic.AdminPasswd)
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx)) // closes request's reader
|
||||
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // importURL built from operator CLI flags, not user input; closes request's reader
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed for %s: %w", importURL, err)
|
||||
}
|
||||
|
||||
@@ -34,13 +34,13 @@ func (rc *RemapCommand) Execute(_ []string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rc.Timeout)
|
||||
defer cancel()
|
||||
remapURL := fmt.Sprintf("%s/api/v1/admin/remap?site=%s", rc.RemarkURL, rc.Site)
|
||||
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader)
|
||||
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader) //nolint:gosec // RemarkURL is operator CLI flag, not user input
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't make remap request for %s: %w", remapURL, err)
|
||||
}
|
||||
req.SetBasicAuth("admin", rc.AdminPasswd)
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // see above
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed for %s: %w", remapURL, err)
|
||||
}
|
||||
|
||||
+210
-25
@@ -2,7 +2,9 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1" //nolint:gosec // used only for stable ID hashing, not for security
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -22,6 +25,7 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/kyokomi/emoji/v2"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
@@ -35,6 +39,7 @@ import (
|
||||
"github.com/umputun/remark42/backend/app/providers"
|
||||
"github.com/umputun/remark42/backend/app/rest/api"
|
||||
"github.com/umputun/remark42/backend/app/rest/proxy"
|
||||
"github.com/umputun/remark42/backend/app/safehttp"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
@@ -74,7 +79,7 @@ type ServerCommand struct {
|
||||
CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"`
|
||||
PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"`
|
||||
ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments, days"`
|
||||
EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window"`
|
||||
EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window; set to 0 to disable comment editing and staged image cleanup"`
|
||||
AdminEdit bool `long:"admin-edit" env:"ADMIN_EDIT" description:"unlimited edit for admins"`
|
||||
Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"`
|
||||
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
|
||||
@@ -99,18 +104,19 @@ type ServerCommand struct {
|
||||
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)"`
|
||||
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"`
|
||||
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
|
||||
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
|
||||
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
|
||||
Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
|
||||
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
|
||||
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"`
|
||||
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
|
||||
Discord AuthGroup `group:"discord" namespace:"discord" env-namespace:"DISCORD" description:"Discord OAuth"`
|
||||
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
|
||||
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
|
||||
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
|
||||
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
|
||||
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
|
||||
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
|
||||
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
|
||||
Microsoft MicrosoftAuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
|
||||
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
|
||||
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"`
|
||||
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
|
||||
Discord AuthGroup `group:"discord" namespace:"discord" env-namespace:"DISCORD" description:"Discord OAuth"`
|
||||
Custom CustomAuthGroup `group:"custom" namespace:"custom" env-namespace:"CUSTOM" description:"Custom OAuth2 provider"`
|
||||
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
|
||||
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
|
||||
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
|
||||
Email struct {
|
||||
Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"`
|
||||
From string `long:"from" env:"FROM" description:"from email address"`
|
||||
@@ -152,6 +158,28 @@ type AuthGroup struct {
|
||||
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
||||
}
|
||||
|
||||
// MicrosoftAuthGroup defines options group for Microsoft auth params
|
||||
type MicrosoftAuthGroup struct {
|
||||
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
||||
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
||||
Tenant string `long:"tenant" env:"TENANT" description:"Azure AD tenant ID, domain, or 'common' (default)" default:"common"`
|
||||
}
|
||||
|
||||
// CustomAuthGroup defines options group for custom OAuth2 provider params
|
||||
type CustomAuthGroup struct {
|
||||
Name string `long:"name" env:"NAME" description:"custom provider name used in auth route"`
|
||||
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
||||
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
||||
AuthURL string `long:"auth-url" env:"AUTH_URL" description:"OAuth authorization endpoint"`
|
||||
TokenURL string `long:"token-url" env:"TOKEN_URL" description:"OAuth token endpoint"`
|
||||
InfoURL string `long:"info-url" env:"INFO_URL" description:"OAuth user info endpoint"`
|
||||
Scopes []string `long:"scopes" env:"SCOPES" env-delim:"," description:"OAuth scopes"`
|
||||
IDField string `long:"id-field" env:"ID_FIELD" default:"sub" description:"user info field used as unique id"`
|
||||
NameField string `long:"name-field" env:"NAME_FIELD" default:"name" description:"user info field used as display name"`
|
||||
PictureField string `long:"picture-field" env:"PICTURE_FIELD" default:"picture" description:"user info field used as avatar url"`
|
||||
EmailField string `long:"email-field" env:"EMAIL_FIELD" default:"email" description:"user info field used as email"`
|
||||
}
|
||||
|
||||
// StoreGroup defines options group for store params
|
||||
type StoreGroup struct {
|
||||
Type string `long:"type" env:"TYPE" description:"type of storage" choice:"bolt" choice:"rpc" default:"bolt"` // nolint
|
||||
@@ -323,6 +351,7 @@ func (s *ServerCommand) Execute(_ []string) error {
|
||||
"AUTH_YANDEX_CSEC",
|
||||
"AUTH_PATREON_CSEC",
|
||||
"AUTH_DISCORD_CSEC",
|
||||
"AUTH_CUSTOM_CSEC",
|
||||
"TELEGRAM_TOKEN",
|
||||
"SMTP_PASSWORD",
|
||||
"ADMIN_PASSWD",
|
||||
@@ -472,12 +501,87 @@ func stringsSetAndDifferent(s1, s2 string) bool {
|
||||
}
|
||||
|
||||
func contains(s string, a []string) bool {
|
||||
for _, t := range a {
|
||||
if t == s {
|
||||
return true
|
||||
return slices.Contains(a, s)
|
||||
}
|
||||
|
||||
var reservedCustomProviderNames = map[string]struct{}{
|
||||
"email": {},
|
||||
"anonymous": {},
|
||||
"google": {},
|
||||
"github": {},
|
||||
"facebook": {},
|
||||
"yandex": {},
|
||||
"twitter": {},
|
||||
"microsoft": {},
|
||||
"patreon": {},
|
||||
"discord": {},
|
||||
"telegram": {},
|
||||
"dev": {},
|
||||
"apple": {},
|
||||
}
|
||||
|
||||
var validCustomProviderName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func isReservedCustomProviderName(name string) bool {
|
||||
_, ok := reservedCustomProviderNames[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func isValidCustomProviderName(name string) bool {
|
||||
return validCustomProviderName.MatchString(name)
|
||||
}
|
||||
|
||||
func customProviderSourceID(data provider.UserData, cfg CustomAuthGroup) string {
|
||||
sourceID := data.Value(cfg.IDField)
|
||||
if sourceID == "" {
|
||||
sourceID = data.Value(cfg.EmailField)
|
||||
}
|
||||
if sourceID == "" {
|
||||
sourceID = data.Value(cfg.NameField)
|
||||
}
|
||||
if sourceID == "" {
|
||||
sourceID = data.Value(cfg.PictureField)
|
||||
}
|
||||
if sourceID == "" {
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to serialize custom oauth user data for ID fallback: %v", err)
|
||||
} else {
|
||||
sourceID = string(payload)
|
||||
}
|
||||
}
|
||||
return false
|
||||
if sourceID == "" || sourceID == "{}" {
|
||||
log.Printf("[WARN] custom oauth provider returned no stable user identifier fields, falling back to hashed payload")
|
||||
}
|
||||
return sourceID
|
||||
}
|
||||
|
||||
func (c CustomAuthGroup) isConfigured() bool {
|
||||
return c.Name != "" || c.CID != "" || c.CSEC != "" || c.AuthURL != "" || c.TokenURL != "" || c.InfoURL != "" ||
|
||||
len(c.Scopes) > 0 || c.IDField != "sub" || c.NameField != "name" || c.PictureField != "picture" || c.EmailField != "email"
|
||||
}
|
||||
|
||||
func (c CustomAuthGroup) missingRequired() []string {
|
||||
missing := []string{}
|
||||
if c.Name == "" {
|
||||
missing = append(missing, "AUTH_CUSTOM_NAME")
|
||||
}
|
||||
if c.CID == "" {
|
||||
missing = append(missing, "AUTH_CUSTOM_CID")
|
||||
}
|
||||
if c.CSEC == "" {
|
||||
missing = append(missing, "AUTH_CUSTOM_CSEC")
|
||||
}
|
||||
if c.AuthURL == "" {
|
||||
missing = append(missing, "AUTH_CUSTOM_AUTH_URL")
|
||||
}
|
||||
if c.TokenURL == "" {
|
||||
missing = append(missing, "AUTH_CUSTOM_TOKEN_URL")
|
||||
}
|
||||
if c.InfoURL == "" {
|
||||
missing = append(missing, "AUTH_CUSTOM_INFO_URL")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// newServerApp prepares application and return it with all active parts
|
||||
@@ -518,7 +622,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
MaxVotes: s.MaxVotes,
|
||||
PositiveScore: s.PositiveScore,
|
||||
ImageService: imageService,
|
||||
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}, s.getAllowedDomains()),
|
||||
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5, Transport: safehttp.Transport()}, s.getAllowedDomains()),
|
||||
RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}),
|
||||
}
|
||||
dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP
|
||||
@@ -678,9 +782,9 @@ func (s *ServerCommand) getAllowedDomains() []string {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only for RemarkURL if domain is not IP and has more than two levels, extract second level domain.
|
||||
// For AllowedHosts we don't do this as they are exact list of domains which can host comments, but
|
||||
// RemarkURL might be on a subdomain and we must allow parent domain to be used for TitleExtract.
|
||||
// only for RemarkURL if domain is not IP and has more than two levels, extract second level domain.
|
||||
// for AllowedHosts we don't do this as they are exact list of domains which can host comments, but
|
||||
// remarkURL might be on a subdomain and we must allow parent domain to be used for TitleExtract.
|
||||
if rawURL == s.RemarkURL && net.ParseIP(domain) == nil && len(strings.Split(domain, ".")) > 2 {
|
||||
domain = strings.Join(strings.Split(domain, ".")[len(strings.Split(domain, "."))-2:], ".")
|
||||
}
|
||||
@@ -690,6 +794,42 @@ func (s *ServerCommand) getAllowedDomains() []string {
|
||||
return allowedDomains
|
||||
}
|
||||
|
||||
// getAllowedRedirectHosts normalises s.AllowedHosts into the form that
|
||||
// go-pkgz/auth's redirect validator expects. Strips http(s) schemes and
|
||||
// paths; preserves explicit ports (the validator matches both host-only
|
||||
// and host:port, so an entry without a port accepts any port while an
|
||||
// entry with a port restricts to that port). Skips CSP sentinels
|
||||
// ('self' / "self") and wildcard entries (*, *.example.com) that are
|
||||
// valid CSP source expressions but not valid hostnames.
|
||||
func (s *ServerCommand) getAllowedRedirectHosts() []string {
|
||||
out := make([]string, 0, len(s.AllowedHosts))
|
||||
for _, raw := range s.AllowedHosts {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "self" || raw == "'self'" || raw == `"self"` {
|
||||
continue
|
||||
}
|
||||
if strings.ContainsRune(raw, '*') { // CSP wildcard, not a host
|
||||
continue
|
||||
}
|
||||
// add scheme so url.Parse populates Hostname()/Host consistently for bare hosts
|
||||
toParse := raw
|
||||
if !strings.HasPrefix(toParse, "http://") && !strings.HasPrefix(toParse, "https://") {
|
||||
toParse = "https://" + toParse
|
||||
}
|
||||
u, err := url.Parse(toParse)
|
||||
if err != nil || u.Hostname() == "" {
|
||||
log.Printf("[WARN] skipping invalid AllowedHosts entry %q for redirect allowlist: %v", raw, err)
|
||||
continue
|
||||
}
|
||||
if u.Port() != "" {
|
||||
out = append(out, u.Host) // preserve explicit host:port so allowlist is port-specific
|
||||
continue
|
||||
}
|
||||
out = append(out, u.Hostname())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Run all application objects
|
||||
func (a *serverApp) run(ctx context.Context) error {
|
||||
if a.AdminPasswd != "" {
|
||||
@@ -939,7 +1079,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Microsoft.CID != "" && s.Auth.Microsoft.CSEC != "" {
|
||||
authenticator.AddProvider("microsoft", s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC)
|
||||
authenticator.AddMicrosoftProvider(s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC, s.Auth.Microsoft.Tenant)
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" {
|
||||
@@ -959,6 +1099,45 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
providersCount++
|
||||
}
|
||||
|
||||
if s.Auth.Custom.isConfigured() {
|
||||
missing := s.Auth.Custom.missingRequired()
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("custom oauth provider configuration is incomplete, missing: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
customName := strings.ToLower(strings.TrimSpace(s.Auth.Custom.Name))
|
||||
if !isValidCustomProviderName(customName) {
|
||||
return fmt.Errorf("custom oauth provider name %q is invalid, expected pattern %q", customName, validCustomProviderName.String())
|
||||
}
|
||||
if isReservedCustomProviderName(customName) {
|
||||
return fmt.Errorf("custom oauth provider name %q is reserved", customName)
|
||||
}
|
||||
|
||||
authenticator.AddCustomProvider(customName, auth.Client{Cid: s.Auth.Custom.CID, Csecret: s.Auth.Custom.CSEC}, provider.CustomHandlerOpt{
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: s.Auth.Custom.AuthURL,
|
||||
TokenURL: s.Auth.Custom.TokenURL,
|
||||
},
|
||||
InfoURL: s.Auth.Custom.InfoURL,
|
||||
Scopes: s.Auth.Custom.Scopes,
|
||||
MapUserFn: func(data provider.UserData, _ []byte) token.User {
|
||||
sourceID := customProviderSourceID(data, s.Auth.Custom)
|
||||
hashID := token.HashID(sha1.New(), sourceID) //nolint:gosec // stable provider user id hash
|
||||
user := token.User{
|
||||
ID: customName + "_" + hashID,
|
||||
Name: data.Value(s.Auth.Custom.NameField),
|
||||
Picture: data.Value(s.Auth.Custom.PictureField),
|
||||
Email: data.Value(s.Auth.Custom.EmailField),
|
||||
}
|
||||
if user.Name == "" {
|
||||
user.Name = "noname_" + hashID[:4]
|
||||
}
|
||||
return user
|
||||
},
|
||||
})
|
||||
providersCount++
|
||||
}
|
||||
|
||||
if s.Auth.Dev {
|
||||
log.Print("[INFO] dev access enabled")
|
||||
u, errURL := url.Parse(s.RemarkURL)
|
||||
@@ -1020,7 +1199,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
}
|
||||
return true, nil
|
||||
}),
|
||||
// Custom user ID generator, used to distinguish anonymous users with the same login
|
||||
// custom user ID generator, used to distinguish anonymous users with the same login
|
||||
// coming from different IPs
|
||||
func(user string, r *http.Request) string {
|
||||
return user + r.RemoteAddr
|
||||
@@ -1105,7 +1284,7 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
|
||||
VerificationSubject: s.Notify.Email.VerificationSubject,
|
||||
UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html",
|
||||
// TODO: uncomment after #560 frontend part is ready and URL is known
|
||||
// SubscribeURL: s.RemarkURL + "/subscribe.html?token=",
|
||||
// subscribeURL: s.RemarkURL + "/subscribe.html?token=",
|
||||
TokenGenFn: func(userID, email, site string) (string, error) {
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: userID + "::" + email},
|
||||
@@ -1208,6 +1387,12 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
||||
SendJWTHeader: s.Auth.SendJWTHeader,
|
||||
SameSiteCookie: s.parseSameSite(s.Auth.SameSite),
|
||||
SecureCookies: strings.HasPrefix(s.RemarkURL, "https://"),
|
||||
// enable the `from` redirect allowlist in go-pkgz/auth v2.1.2+ — limits
|
||||
// post-auth redirects to RemarkURL's own host plus any configured
|
||||
// AllowedHosts. Prevents the OAuth open-redirect / phishing vector.
|
||||
AllowedRedirectHosts: token.AllowedHostsFunc(func() ([]string, error) {
|
||||
return s.getAllowedRedirectHosts(), nil
|
||||
}),
|
||||
SecretReader: token.SecretFunc(func(aud string) (string, error) { // get secret per site
|
||||
return admns.Key(aud)
|
||||
}),
|
||||
@@ -1215,7 +1400,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
||||
if c.User == nil {
|
||||
return c
|
||||
}
|
||||
// Audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
||||
// audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
||||
if len(c.Audience) != 1 {
|
||||
return c
|
||||
}
|
||||
|
||||
+153
-11
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/auth/v2/provider"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jessevdk/go-flags"
|
||||
@@ -47,7 +48,7 @@ func TestServerApp(t *testing.T) {
|
||||
// add comment
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
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)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
@@ -95,6 +96,30 @@ func TestServerApp_DevMode(t *testing.T) {
|
||||
app.Wait()
|
||||
}
|
||||
|
||||
func TestServerApp_CustomOAuthProvider(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
o.Port = port
|
||||
o.Auth.Custom.Name = "oidc"
|
||||
o.Auth.Custom.CID = "cid"
|
||||
o.Auth.Custom.CSEC = "csec"
|
||||
o.Auth.Custom.AuthURL = "https://example.com/oauth2/authorize"
|
||||
o.Auth.Custom.TokenURL = "https://example.com/oauth2/token"
|
||||
o.Auth.Custom.InfoURL = "https://example.com/oauth2/userinfo"
|
||||
return o
|
||||
})
|
||||
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 11+1, len(providers), "extra auth provider")
|
||||
assert.Equal(t, "oidc", providers[len(providers)-2].Name(), "custom auth provider")
|
||||
|
||||
cancel()
|
||||
app.Wait()
|
||||
}
|
||||
|
||||
func TestServerApp_AnonMode(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||
@@ -129,7 +154,7 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// try to add a comment as good anonymous
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
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)
|
||||
|
||||
@@ -194,7 +219,7 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
|
||||
// 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", port),
|
||||
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)
|
||||
|
||||
@@ -389,6 +414,95 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
"failed to make authenticator: an AppleProvider creating failed: "+
|
||||
"provided private key is not ECDSA")
|
||||
t.Log(err)
|
||||
|
||||
// incomplete custom oauth config
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&opts, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--backup=/tmp", "--image.fs.path=/tmp", "--auth.custom.name=oidc", "--auth.custom.cid=123"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err,
|
||||
"failed to make authenticator: custom oauth provider configuration is incomplete, missing: "+
|
||||
"AUTH_CUSTOM_CSEC, AUTH_CUSTOM_AUTH_URL, AUTH_CUSTOM_TOKEN_URL, AUTH_CUSTOM_INFO_URL")
|
||||
t.Log(err)
|
||||
}
|
||||
|
||||
func TestIsReservedCustomProviderName(t *testing.T) {
|
||||
reserved := []string{
|
||||
"email", "anonymous", "google", "github", "facebook", "yandex", "twitter",
|
||||
"microsoft", "patreon", "discord", "telegram", "dev", "apple",
|
||||
}
|
||||
|
||||
for _, name := range reserved {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.True(t, isReservedCustomProviderName(name))
|
||||
})
|
||||
}
|
||||
|
||||
assert.False(t, isReservedCustomProviderName("oidc"))
|
||||
}
|
||||
|
||||
func TestIsValidCustomProviderName(t *testing.T) {
|
||||
valid := []string{"oidc", "codeberg", "provider_1", "provider-1", "a1"}
|
||||
for _, name := range valid {
|
||||
t.Run("valid_"+name, func(t *testing.T) {
|
||||
assert.True(t, isValidCustomProviderName(name))
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []string{"", " has-space", "has space", "Uppercase", "provider!", "-provider", "_provider"}
|
||||
for _, name := range invalid {
|
||||
t.Run("invalid_"+strings.ReplaceAll(name, " ", "_"), func(t *testing.T) {
|
||||
assert.False(t, isValidCustomProviderName(name))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomProviderSourceID(t *testing.T) {
|
||||
cfg := CustomAuthGroup{IDField: "sub", EmailField: "email", NameField: "name", PictureField: "picture"}
|
||||
|
||||
assert.Equal(t, "user-1", customProviderSourceID(provider.UserData{"sub": "user-1", "email": "a@example.com"}, cfg))
|
||||
assert.Equal(t, "a@example.com", customProviderSourceID(provider.UserData{"email": "a@example.com"}, cfg))
|
||||
assert.Equal(t, "alice", customProviderSourceID(provider.UserData{"name": "alice"}, cfg))
|
||||
assert.Equal(t, "https://example.com/avatar.png", customProviderSourceID(provider.UserData{"picture": "https://example.com/avatar.png"}, cfg))
|
||||
assert.Equal(t, `{"login":"alice"}`, customProviderSourceID(provider.UserData{"login": "alice"}, cfg))
|
||||
assert.Equal(t, "{}", customProviderSourceID(provider.UserData{}, cfg))
|
||||
}
|
||||
|
||||
func TestServerApp_InvalidCustomOAuthProviderName(t *testing.T) {
|
||||
baseArgs := []string{
|
||||
"--store.bolt.path=/tmp",
|
||||
"--backup=/tmp",
|
||||
"--image.fs.path=/tmp",
|
||||
"--auth.custom.cid=123",
|
||||
"--auth.custom.csec=456",
|
||||
"--auth.custom.auth-url=https://example.com/oauth2/authorize",
|
||||
"--auth.custom.token-url=https://example.com/oauth2/token",
|
||||
"--auth.custom.info-url=https://example.com/oauth2/userinfo",
|
||||
}
|
||||
|
||||
t.Run("reserved", func(t *testing.T) {
|
||||
opts := ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p := flags.NewParser(&opts, flags.Default)
|
||||
_, err := p.ParseArgs(append(baseArgs, "--auth.custom.name=twitter"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, `failed to make authenticator: custom oauth provider name "twitter" is reserved`)
|
||||
})
|
||||
|
||||
t.Run("not_url_safe", func(t *testing.T) {
|
||||
opts := ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p := flags.NewParser(&opts, flags.Default)
|
||||
_, err := p.ParseArgs(append(baseArgs, "--auth.custom.name=bad name"))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, `failed to make authenticator: custom oauth provider name "bad name" is invalid, expected pattern "^[a-z0-9][a-z0-9_-]*$"`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerApp_Shutdown(t *testing.T) {
|
||||
@@ -628,7 +742,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// add comment
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
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/p/2018/12/29/podcast-630/", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-JWT", tk)
|
||||
@@ -643,7 +757,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
tkNoAud, err := tkService.Token(badClaimsNoAud)
|
||||
require.NoError(t, err)
|
||||
t.Logf("no-aud claims: %s", tkNoAud)
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
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/p/2018/12/29/podcast-631/",
|
||||
"site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
@@ -661,7 +775,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
tkMultipleAuds, err := tkService.Token(badClaimsMultipleAud)
|
||||
require.NoError(t, err)
|
||||
t.Logf("multiple aud claims: %s", tkMultipleAuds)
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
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/p/2018/12/29/podcast-631/",
|
||||
"site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
@@ -680,7 +794,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
tkNoUser, err := tkService.Token(badClaimsNoUser)
|
||||
require.NoError(t, err)
|
||||
t.Logf("no user claims: %s", tkNoUser)
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
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/p/2018/12/29/podcast-631/",
|
||||
"site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
@@ -706,7 +820,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
t.Log(string(b))
|
||||
|
||||
// try add a comment with blocked user
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||
strings.NewReader(`{"text": "test 123 blah", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-JWT", tk)
|
||||
@@ -788,8 +902,36 @@ func Test_getAllowedDomains(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getAllowedRedirectHosts(t *testing.T) {
|
||||
tbl := []struct {
|
||||
name string
|
||||
hosts []string
|
||||
want []string
|
||||
}{
|
||||
{name: "empty", hosts: nil, want: []string{}},
|
||||
{name: "bare hostnames pass through", hosts: []string{"example.com", "admin.example.com"}, want: []string{"example.com", "admin.example.com"}},
|
||||
{name: "https scheme stripped", hosts: []string{"https://example.com"}, want: []string{"example.com"}},
|
||||
{name: "http scheme stripped", hosts: []string{"http://example.com"}, want: []string{"example.com"}},
|
||||
{name: "scheme with path strips path", hosts: []string{"https://example.com/embed"}, want: []string{"example.com"}},
|
||||
{name: "explicit port preserved as host:port", hosts: []string{"example.com:8080"}, want: []string{"example.com:8080"}},
|
||||
{name: "scheme with explicit port preserved", hosts: []string{"https://example.com:8443"}, want: []string{"example.com:8443"}},
|
||||
{name: "scheme without port stays bare host", hosts: []string{"https://example.com"}, want: []string{"example.com"}},
|
||||
{name: "self sentinel filtered", hosts: []string{"'self'", "self", `"self"`, "example.com"}, want: []string{"example.com"}},
|
||||
{name: "wildcards filtered", hosts: []string{"*", "*.example.com", "https://*.example.com", "example.com"}, want: []string{"example.com"}},
|
||||
{name: "empty entries filtered", hosts: []string{"", " ", "example.com"}, want: []string{"example.com"}},
|
||||
{name: "mixed real-world", hosts: []string{"'self'", "https://blog.example.com", "admin.example.com:8443", "*.cdn.example.com"},
|
||||
want: []string{"blog.example.com", "admin.example.com:8443"}},
|
||||
}
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := ServerCommand{AllowedHosts: tt.hosts}
|
||||
assert.Equal(t, tt.want, s.getAllowedRedirectHosts())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
@@ -803,7 +945,7 @@ func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 3 seconds for server to start before returning it
|
||||
client := http.Client{Timeout: time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for i := 0; i < 300; i++ {
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
@@ -814,7 +956,7 @@ func waitForHTTPServerStart(port int) {
|
||||
|
||||
func waitForHTTPSServerStart(port int) {
|
||||
// wait for up to 3 seconds for HTTPS server to start
|
||||
for i := 0; i < 300; i++ {
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
||||
if conn != nil {
|
||||
|
||||
+1
-4
@@ -92,10 +92,7 @@ func logDeprecatedParams(params []cmd.DeprecatedFlag) {
|
||||
func getDump() string {
|
||||
maxSize := 5 * 1024 * 1024
|
||||
stacktrace := make([]byte, maxSize)
|
||||
length := runtime.Stack(stacktrace, true)
|
||||
if length > maxSize {
|
||||
length = maxSize
|
||||
}
|
||||
length := min(runtime.Stack(stacktrace, true), maxSize)
|
||||
return string(stacktrace[:length])
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestGetDump(t *testing.T) {
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
@@ -144,7 +144,7 @@ func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 10 seconds for server to start before returning it
|
||||
client := http.Client{Timeout: time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -59,19 +60,21 @@ func TestBackup_Do(t *testing.T) {
|
||||
defer os.RemoveAll(loc)
|
||||
assert.NoError(t, os.MkdirAll(loc, 0o700))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
cancel()
|
||||
}()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}, Duration: 600 * time.Millisecond}
|
||||
bk.Do(ctx)
|
||||
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}, Duration: 600 * time.Millisecond}
|
||||
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())
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
type mockExporter struct{}
|
||||
|
||||
@@ -48,7 +48,7 @@ type commentoCommenter struct {
|
||||
Link string `json:"link"`
|
||||
Photo string `json:"photo"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
JoinDate time.Time `json:"joinDate,omitempty"`
|
||||
JoinDate time.Time `json:"joinDate"`
|
||||
IsModerator bool `json:"isModerator"`
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestDisqus_Convert(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
ch := d.convert(fh, "test")
|
||||
|
||||
res := []store.Comment{}
|
||||
res := make([]store.Comment, 0, 4)
|
||||
for comment := range ch {
|
||||
res = append(res, comment)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (u *URLMapper) loadRules(reader io.Reader) error {
|
||||
|
||||
u.rules = make(map[string]string)
|
||||
|
||||
for _, row := range strings.Split(rulesText, "\n") {
|
||||
for row := range strings.SplitSeq(rulesText, "\n") {
|
||||
row = strings.TrimSpace(row)
|
||||
urls := strings.Split(row, " ")
|
||||
if len(urls) != 2 {
|
||||
@@ -64,8 +64,8 @@ func (u *URLMapper) URL(url string) string {
|
||||
}
|
||||
oldURL = strings.TrimSuffix(oldURL, "*")
|
||||
newURL = strings.TrimSuffix(newURL, "*")
|
||||
if strings.HasPrefix(url, oldURL) {
|
||||
return newURL + strings.TrimPrefix(url, oldURL)
|
||||
if after, ok := strings.CutPrefix(url, oldURL); ok {
|
||||
return newURL + after
|
||||
}
|
||||
}
|
||||
// search failed, return given url
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestNative_ImportManyWithError(t *testing.T) {
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := range 100 {
|
||||
fmt.Fprintf(buf, goodRec, i)
|
||||
}
|
||||
buf.WriteString("{}\n")
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestWordPress_Convert(t *testing.T) {
|
||||
wp := WordPress{}
|
||||
ch := wp.convert(strings.NewReader(xmlTestWP), "testWP")
|
||||
|
||||
comments := []store.Comment{}
|
||||
comments := make([]store.Comment, 0, 3)
|
||||
for c := range ch {
|
||||
comments = append(comments, c)
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestWP_Convert_MD(t *testing.T) {
|
||||
wp := WordPress{}
|
||||
ch := wp.convert(strings.NewReader(xmlTestWPmd), "siteID")
|
||||
|
||||
comments := []store.Comment{}
|
||||
comments := make([]store.Comment, 0, 3)
|
||||
for c := range ch {
|
||||
comments = append(comments, c)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type EmailParams struct {
|
||||
SubscribeURL string // full subscribe handler URL
|
||||
UnsubscribeURL string // full unsubscribe handler URL
|
||||
|
||||
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
|
||||
TokenGenFn func(userID, email, site string) (string, error) // unsubscribe token generation function
|
||||
}
|
||||
|
||||
// Email implements notify.Destination for email
|
||||
@@ -168,7 +168,7 @@ func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email stri
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
||||
email,
|
||||
e.From,
|
||||
url.QueryEscape(e.From),
|
||||
url.QueryEscape(msg.unsubscribeLink),
|
||||
url.QueryEscape(msg.subject),
|
||||
),
|
||||
@@ -203,7 +203,7 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
||||
req.Email,
|
||||
e.From,
|
||||
url.QueryEscape(e.From),
|
||||
url.QueryEscape(e.VerificationSubject),
|
||||
),
|
||||
msg,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
)
|
||||
@@ -22,14 +21,13 @@ type MockDest struct {
|
||||
func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
m.data = append(m.data, r)
|
||||
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
log.Printf("ctx closed %d", m.id)
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
m.data = append(m.data, r)
|
||||
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,14 +35,13 @@ func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
m.verificationData = append(m.verificationData, v)
|
||||
log.Printf("sent verification %s -> %d", v.User, m.id)
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); err != nil {
|
||||
log.Printf("verification ctx closed %d", m.id)
|
||||
m.closed = true
|
||||
return nil
|
||||
}
|
||||
m.verificationData = append(m.verificationData, v)
|
||||
log.Printf("sent verification %s -> %d", v.User, m.id)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+195
-190
@@ -2,10 +2,9 @@ package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
"testing/synctest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -26,250 +25,256 @@ func TestService_NoDestinations(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_WithDestinations(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Close()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||
synctest.Wait()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||
synctest.Wait()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||
synctest.Wait()
|
||||
s.Close()
|
||||
|
||||
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
|
||||
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
|
||||
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
|
||||
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
|
||||
|
||||
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
||||
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
||||
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
|
||||
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
||||
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
||||
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_WithDrops(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
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"}})
|
||||
time.Sleep(time.Millisecond * 21)
|
||||
s.Close()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||
synctest.Wait()
|
||||
s.Close()
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after 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())
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_SubmitVerificationWithDrops(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
s.SubmitVerification(VerificationRequest{
|
||||
SiteID: "remark",
|
||||
User: "testUser",
|
||||
Email: "test@example.org",
|
||||
Token: "testToken",
|
||||
s.SubmitVerification(VerificationRequest{
|
||||
SiteID: "remark",
|
||||
User: "testUser",
|
||||
Email: "test@example.org",
|
||||
Token: "testToken",
|
||||
})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
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())
|
||||
|
||||
verifyDest := d1.GetVerify()
|
||||
require.LessOrEqual(t, len(verifyDest), 2, "one request from 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)
|
||||
})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
time.Sleep(time.Millisecond * 21)
|
||||
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())
|
||||
|
||||
verifyDest := d1.GetVerify()
|
||||
require.LessOrEqual(t, len(verifyDest), 2, "one request from 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)
|
||||
}
|
||||
|
||||
func TestService_Many(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 5, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 5, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
|
||||
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
|
||||
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(20)))
|
||||
}
|
||||
s.Close()
|
||||
for i := range 10 {
|
||||
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
|
||||
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
|
||||
}
|
||||
s.Close()
|
||||
|
||||
// wait for destinations to close
|
||||
assert.Eventually(t, func() bool { return d1.IsClosed() && d2.IsClosed() }, 100*time.Millisecond, 10*time.Millisecond)
|
||||
|
||||
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
|
||||
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
|
||||
|
||||
assert.True(t, d1.IsClosed())
|
||||
assert.True(t, d2.IsClosed())
|
||||
assert.Equal(t, "mock id=1, closed=true", d1.String())
|
||||
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
|
||||
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_WithParent(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}}
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}}
|
||||
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1"}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2"}
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1"}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2"}
|
||||
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
s.Close()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
|
||||
synctest.Wait()
|
||||
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
|
||||
synctest.Wait()
|
||||
s.Close()
|
||||
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
|
||||
assert.Equal(t, "p1", destRes[0].parent.ID)
|
||||
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
|
||||
assert.Equal(t, "", destRes[1].parent.ID)
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
|
||||
assert.Equal(t, "p1", destRes[0].parent.ID)
|
||||
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
|
||||
assert.Equal(t, "", destRes[1].parent.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_EmailRetrieval(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
||||
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
|
||||
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||
dataStore.userDetails["u1"] = "u1@example.com"
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
|
||||
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||
dataStore.userDetails["u1"] = "u1@example.com"
|
||||
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
// one comment, one notification
|
||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// one comment, one notification
|
||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||
assert.Empty(t, destRes[0].parent)
|
||||
assert.Empty(t, destRes[0].Emails)
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||
assert.Empty(t, destRes[0].parent)
|
||||
assert.Empty(t, destRes[0].Emails)
|
||||
|
||||
// reply to the first comment, same comment as one in original comment
|
||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// reply to the first comment, same comment as one in original comment
|
||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
|
||||
|
||||
// another reply to the first comment, another user
|
||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// another reply to the first comment, another user
|
||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[2].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[2].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[2].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[2].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||
|
||||
// reply to the last comment by another user, should trigger email retrieval error
|
||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// reply to the last comment by another user, should trigger email retrieval error
|
||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 4, len(destRes), "four comment notified")
|
||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||
assert.Equal(t, "u2", destRes[3].parent.User.ID)
|
||||
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 4, len(destRes), "four comment notified")
|
||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||
assert.Equal(t, "u2", destRes[3].parent.User.ID)
|
||||
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
|
||||
|
||||
s.Close()
|
||||
s.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Recursive(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
||||
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
|
||||
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
|
||||
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
|
||||
dataStore.userDetails["u1"] = "u1@example.com"
|
||||
// second comment goes without email address for notification
|
||||
dataStore.userDetails["u3"] = "u3@example.com"
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
|
||||
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
|
||||
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
|
||||
dataStore.userDetails["u1"] = "u1@example.com"
|
||||
// second comment goes without email address for notification
|
||||
dataStore.userDetails["u3"] = "u3@example.com"
|
||||
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
// one comment from u1 with email set
|
||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// one comment from u1 with email set
|
||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||
assert.Empty(t, destRes[0].parent)
|
||||
assert.Empty(t, destRes[0].Emails)
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||
assert.Empty(t, destRes[0].parent)
|
||||
assert.Empty(t, destRes[0].Emails)
|
||||
|
||||
// reply to the first comment from u2 without email set
|
||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// reply to the first comment from u2 without email set
|
||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
|
||||
|
||||
// reply to the second comment from u3 with email set
|
||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// reply to the second comment from u3 with email set
|
||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||
assert.Equal(t, "p2", destRes[2].parent.ID)
|
||||
assert.Equal(t, "u2", destRes[2].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||
assert.Equal(t, "p2", destRes[2].parent.ID)
|
||||
assert.Equal(t, "u2", destRes[2].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||
|
||||
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
|
||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
|
||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 4, len(destRes), "four comment notified once each")
|
||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||
assert.Equal(t, "u3", destRes[3].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 4, len(destRes), "four comment notified once each")
|
||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||
assert.Equal(t, "u3", destRes[3].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
|
||||
|
||||
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
|
||||
s.Submit(Request{Comment: dataStore.data["p5"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
|
||||
s.Submit(Request{Comment: dataStore.data["p5"]})
|
||||
synctest.Wait()
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 5, len(destRes), "four comment notified once each")
|
||||
assert.Equal(t, "p5", destRes[4].Comment.ID)
|
||||
assert.Equal(t, "p4", destRes[4].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[4].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 5, len(destRes), "four comment notified once each")
|
||||
assert.Equal(t, "p5", destRes[4].Comment.ID)
|
||||
assert.Equal(t, "p4", destRes[4].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[4].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
|
||||
|
||||
s.Close()
|
||||
s.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Nop(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
type tgRequester interface {
|
||||
Request(ctx context.Context, method string, b []byte, data interface{}) error
|
||||
Request(ctx context.Context, method string, b []byte, data any) error
|
||||
}
|
||||
|
||||
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
|
||||
@@ -27,8 +27,8 @@ type TGUpdatesReceiver interface {
|
||||
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
|
||||
// Blocks caller
|
||||
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
|
||||
// Identifier of the first update to be requested.
|
||||
// Should be equal to LastSeenUpdateID + 1
|
||||
// identifier of the first update to be requested.
|
||||
// should be equal to LastSeenUpdateID + 1
|
||||
// See https://core.telegram.org/bots/api#getupdates
|
||||
var updateOffset int
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
@@ -12,12 +13,14 @@ import (
|
||||
)
|
||||
|
||||
func TestDispatchTelegramUpdates(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
poolPeriod := time.Millisecond * 100
|
||||
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
|
||||
time.Sleep(poolPeriod * 3)
|
||||
cancel()
|
||||
time.Sleep(poolPeriod)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
poolPeriod := time.Millisecond * 100
|
||||
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
|
||||
time.Sleep(poolPeriod * 3)
|
||||
cancel()
|
||||
synctest.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
const getUpdatesResp = `{
|
||||
@@ -39,7 +42,7 @@ type mockTGRequester struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data interface{}) error {
|
||||
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data any) error {
|
||||
if m.hit < 2 {
|
||||
m.hit++
|
||||
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
|
||||
|
||||
@@ -103,7 +103,7 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
||||
// audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
||||
if len(claims.Audience) != 1 {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("bad request"), "can't process token, claims.Audience expected to be a single element but it's not", rest.ErrActionRejected)
|
||||
return
|
||||
|
||||
@@ -87,7 +87,7 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
// check count updated
|
||||
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
b := map[string]interface{}{}
|
||||
b := map[string]any{}
|
||||
err = json.Unmarshal([]byte(res), &b)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("%#v", b)
|
||||
@@ -465,7 +465,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
|
||||
b, err := json.Marshal(c)
|
||||
assert.NoError(t, err, "can't marshal comment %+v", c)
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
@@ -489,7 +489,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
|
||||
b, err = json.Marshal(c)
|
||||
assert.NoError(t, err, "can't marshal comment %+v", c)
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
@@ -718,7 +718,7 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
||||
User: &token.User{
|
||||
ID: "user1",
|
||||
Picture: "pic.image",
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
},
|
||||
@@ -786,7 +786,7 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "provider1_user1",
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -73,6 +73,7 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||
return
|
||||
@@ -180,7 +181,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if e = os.Remove(fh.Name()); e != nil {
|
||||
if e = os.Remove(fh.Name()); e != nil { //nolint:gosec // fh.Name() is from os.CreateTemp, server-controlled
|
||||
log.Printf("[WARN] failed to remove temp file %+v", e)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -276,8 +276,8 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
||||
"picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
|
||||
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},"score":0,
|
||||
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
|
||||
recs := []string{}
|
||||
for i := 0; i < 50; i++ {
|
||||
recs := make([]string, 0, 50)
|
||||
for i := range 50 {
|
||||
recs = append(recs, fmt.Sprintf(tmpl, i))
|
||||
}
|
||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
|
||||
@@ -329,7 +329,7 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
|
||||
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
|
||||
nRecs := 50
|
||||
recs := make([]string, 0, nRecs)
|
||||
for i := 0; i < nRecs; i++ {
|
||||
for i := range nRecs {
|
||||
recs = append(recs, fmt.Sprintf(tmpl, i))
|
||||
}
|
||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with `nRecs` records
|
||||
|
||||
@@ -96,12 +96,12 @@ const lastCommentsScope = "last"
|
||||
|
||||
type commentsWithInfo struct {
|
||||
Comments []store.Comment `json:"comments"`
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
Info store.PostInfo `json:"info"`
|
||||
}
|
||||
|
||||
type treeWithInfo struct {
|
||||
*service.Tree
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
Info store.PostInfo `json:"info"`
|
||||
}
|
||||
|
||||
// Run the lister and request's router, activate rest server
|
||||
@@ -249,6 +249,7 @@ func (s *Rest) routes() chi.Router {
|
||||
|
||||
// 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))
|
||||
@@ -269,7 +270,6 @@ func (s *Rest) routes() chi.Router {
|
||||
ropen.Post("/counts", s.pubRest.countMultiCtrl)
|
||||
ropen.Get("/list", s.pubRest.listCtrl)
|
||||
ropen.Get("/info", s.pubRest.infoCtrl)
|
||||
ropen.Get("/img", s.ImageProxy.Handler)
|
||||
|
||||
ropen.Route("/rss", func(rrss chi.Router) {
|
||||
rrss.Get("/post", s.rssRest.postCommentsCtrl)
|
||||
@@ -278,11 +278,17 @@ func (s *Rest) routes() chi.Router {
|
||||
})
|
||||
})
|
||||
|
||||
// open routes, cached
|
||||
// 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)
|
||||
})
|
||||
@@ -504,7 +510,7 @@ func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) {
|
||||
})
|
||||
}
|
||||
|
||||
func encodeJSONWithHTML(v interface{}) ([]byte, error) {
|
||||
func encodeJSONWithHTML(v any) ([]byte, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
@@ -584,7 +590,9 @@ func matchSiteID(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
if siteID != "" && user.SiteID != siteID {
|
||||
// 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
|
||||
}
|
||||
@@ -618,7 +626,34 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h
|
||||
}
|
||||
}
|
||||
|
||||
// securityHeadersMiddleware sets security-related headers: Content-Security-Policy and Permissions-Policy
|
||||
// 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) {
|
||||
@@ -630,8 +665,11 @@ func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string
|
||||
if len(allowedAncestors) > 0 {
|
||||
frameAncestors = strings.Join(allowedAncestors, " ")
|
||||
}
|
||||
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 data:; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -120,7 +121,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
comment.PrepareUntrusted() // clean all fields user not supposed to set
|
||||
comment.User = user
|
||||
comment.User.IP = strings.Split(r.RemoteAddr, ":")[0]
|
||||
comment.User.IP = extractIP(r.RemoteAddr)
|
||||
|
||||
comment.Orig = comment.Text // original comment text, prior to md render
|
||||
if err := s.dataService.ValidateComment(&comment); err != nil {
|
||||
@@ -279,7 +280,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
Locator: locator,
|
||||
CommentID: id,
|
||||
UserID: user.ID,
|
||||
UserIP: strings.Split(r.RemoteAddr, ":")[0],
|
||||
UserIP: extractIP(r.RemoteAddr),
|
||||
Val: vote,
|
||||
}
|
||||
comment, err := s.dataService.Vote(req)
|
||||
@@ -410,7 +411,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
||||
fmt.Errorf("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected)
|
||||
return
|
||||
}
|
||||
// Generate and send token
|
||||
// generate and send token
|
||||
tkn, err := randToken()
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal)
|
||||
@@ -479,7 +480,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Handshake.ID is user.ID + "::" + address
|
||||
// handshake.ID is user.ID + "::" + address
|
||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||
if len(elems) != 2 || elems[0] != user.ID {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||
@@ -533,7 +534,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handshake.ID is user.ID + "::" + address
|
||||
// handshake.ID is user.ID + "::" + address
|
||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||
if len(elems) != 2 {
|
||||
rest.SendErrorHTML(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||
@@ -578,7 +579,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// MustExecute behaves like template.Execute, but panics if an error occurs.
|
||||
MustExecute := func(tmpl *template.Template, wr io.Writer, data interface{}) {
|
||||
MustExecute := func(tmpl *template.Template, wr io.Writer, data any) {
|
||||
if err := tmpl.Execute(wr, data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -669,7 +670,7 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
merr = multierror.Append(merr, write([]byte(`, "comments":`))) // send comments prefix
|
||||
|
||||
// get comments in 100 in each paginated request
|
||||
for i := 0; i < 100; i++ {
|
||||
for i := range 100 {
|
||||
comments, errUser := s.dataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r))
|
||||
if errUser != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal)
|
||||
@@ -710,7 +711,7 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
User: &token.User{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true, // prevents this token from being used for login
|
||||
},
|
||||
},
|
||||
@@ -730,7 +731,11 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
|
||||
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32*1024*1024) // hard cap on upload to prevent memory exhaustion
|
||||
// gosec G120: r.Body is already bounded by MaxBytesReader on the line above (32 MB),
|
||||
// so ParseMultipartForm cannot read more than that regardless of the in-memory threshold.
|
||||
// The 5 MB argument is the soft threshold above which the form is spilled to disk.
|
||||
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { //nolint:gosec // bounded by MaxBytesReader above
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
@@ -772,3 +777,13 @@ func randToken() (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%x", s.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// extractIP returns the IP portion of the remote address, handling both IPv4 and IPv6 formats.
|
||||
// supports "ip:port", "[ip]:port", and bare "ip" formats.
|
||||
func extractIP(remoteAddr string) string {
|
||||
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return remoteAddr // already a bare IP (no port)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestRest_Create(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||
assert.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -49,7 +49,7 @@ func TestRest_Create(t *testing.T) {
|
||||
c := R.JSON{}
|
||||
err = json.Unmarshal(b, &c)
|
||||
assert.NoError(t, err)
|
||||
loc := c["locator"].(map[string]interface{})
|
||||
loc := c["locator"].(map[string]any)
|
||||
assert.Equal(t, "remark42", loc["site"])
|
||||
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
||||
assert.True(t, len(c["id"].(string)) > 8)
|
||||
@@ -60,7 +60,7 @@ func TestRest_CreateFilteredCode(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "`+"`foo<bar>`"+`", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||
assert.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -71,7 +71,7 @@ func TestRest_CreateFilteredCode(t *testing.T) {
|
||||
c := R.JSON{}
|
||||
err = json.Unmarshal(b, &c)
|
||||
require.NoError(t, err, string(b))
|
||||
loc := c["locator"].(map[string]interface{})
|
||||
loc := c["locator"].(map[string]any)
|
||||
assert.Equal(t, "remark42", loc["site"])
|
||||
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
||||
assert.Equal(t, "`foo<bar>`", c["orig"])
|
||||
@@ -93,6 +93,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) {
|
||||
RoutePath: "/api/v1/img",
|
||||
RemarkURL: srv.RemarkURL,
|
||||
ImageService: srv.ImageService,
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
srv.CommentFormatter = store.NewCommentFormatter(srv.ImageProxy)
|
||||
// need to recreate the server with new ImageProxy, otherwise old one will be used
|
||||
@@ -109,7 +110,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) {
|
||||
defer pngServer.Close()
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||
assert.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -122,7 +123,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) {
|
||||
require.NoError(t, err, string(b))
|
||||
assert.NotContains(t, c["text"], pngServer.URL)
|
||||
assert.Contains(t, c["text"], srv.RemarkURL)
|
||||
loc := c["locator"].(map[string]interface{})
|
||||
loc := c["locator"].(map[string]any)
|
||||
assert.Equal(t, "remark42", loc["site"])
|
||||
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
||||
assert.True(t, len(c["id"].(string)) > 8)
|
||||
@@ -175,7 +176,7 @@ func TestRest_CreateOldPost(t *testing.T) {
|
||||
assert.Equal(t, 1, len(comments))
|
||||
|
||||
// try to add new comment to the same old post
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
@@ -188,7 +189,7 @@ func TestRest_CreateOldPost(t *testing.T) {
|
||||
_, err = srv.DataService.Create(old)
|
||||
assert.NoError(t, err)
|
||||
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
@@ -201,7 +202,7 @@ func TestRest_CreateTooBig(t *testing.T) {
|
||||
|
||||
longComment := fmt.Sprintf(`{"text": "%4001s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, "Щ")
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment", longComment)
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", longComment)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -214,7 +215,7 @@ func TestRest_CreateTooBig(t *testing.T) {
|
||||
assert.Equal(t, "invalid comment", c["details"])
|
||||
|
||||
veryLongComment := fmt.Sprintf(`{"text": "%70000s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, "Щ")
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment", veryLongComment)
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment?site=remark42", veryLongComment)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
@@ -234,7 +235,7 @@ func TestRest_CreateWithRestrictedWord(t *testing.T) {
|
||||
badComment := `{"text": "What the duck is that?", "locator":{"url": "https://radio-t.com/blah1",
|
||||
"site": "remark42"}}`
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment", badComment)
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", badComment)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -253,7 +254,7 @@ func TestRest_CreateRelativeURL(t *testing.T) {
|
||||
|
||||
// check that it's not possible to click insert URL button and not alter the URL in it (which is `url` by default)
|
||||
relativeURLText := `{"text": "here is a link with relative URL: [google.com](url)", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment", relativeURLText)
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", relativeURLText)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -272,7 +273,7 @@ func TestRest_CreateRejected(t *testing.T) {
|
||||
body := `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
||||
|
||||
// try to create without auth
|
||||
resp, err := http.Post(ts.URL+"/api/v1/comment", "", strings.NewReader(body))
|
||||
resp, err := http.Post(ts.URL+"/api/v1/comment?site=remark42", "", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
@@ -280,7 +281,7 @@ func TestRest_CreateRejected(t *testing.T) {
|
||||
// try with wrong aud
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(body))
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devTokenBadAud)
|
||||
resp, err = client.Do(req)
|
||||
@@ -294,7 +295,7 @@ func TestRest_CreateWithWrongImage(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
// create comment
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment", fmt.Sprintf(`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", fmt.Sprintf(`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -316,7 +317,7 @@ func TestRest_CreateWithLazyImage(t *testing.T) {
|
||||
defer teardown()
|
||||
body := `{"text": "test 123 ", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
||||
// create comment
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment", body)
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
@@ -333,7 +334,7 @@ func TestRest_CreateAndGet(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
// create comment
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "**test** *123*\n\n http://radio-t.com", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -372,7 +373,7 @@ func TestRest_CreateWithQuotes(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
// create comment with quotes with smartypants
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "smartpants \"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -395,7 +396,7 @@ func TestRest_CreateWithQuotes(t *testing.T) {
|
||||
|
||||
// create comment with quotes without smartypants
|
||||
srv.privRest.disableFancyTextFormatting = true
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment",
|
||||
resp, err = post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||
`{"text": "no_smartpants \"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -881,30 +882,30 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
body string
|
||||
}{
|
||||
{description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusForbidden},
|
||||
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "set user email, token not set", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||
{description: "set user email, token not set, old query param", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send email confirmation without address", url: "/api/v1/email/subscribe", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||
{description: "send email confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||
{description: "send email confirmation without address, old query param", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "send email confirmation", url: "/api/v1/email/subscribe", method: http.MethodPost, responseCode: http.StatusOK, body: `{"site":"remark42","address":"good@example.com"}`},
|
||||
{description: "send email confirmation", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusOK, body: `{"site":"remark42","address":"good@example.com"}`},
|
||||
{description: "send email confirmation, old query param", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user email, token is good", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||
{description: "set user email, token is good", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
|
||||
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||
{description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "set user email, token is good", url: "/api/v1/email/confirm", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||
{description: "set user email, token is good", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||
{description: "unsubscribe user, no token", url: "/email/unsubscribe.html?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||
{description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden},
|
||||
{description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
|
||||
{description: "unsubscribe user second time, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusConflict},
|
||||
{description: "issue delete request without auth", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
||||
{description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusForbidden},
|
||||
{description: "delete non-existent user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||
{description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusBadRequest},
|
||||
{description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusForbidden},
|
||||
{description: "send telegram 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},
|
||||
{description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusConflict},
|
||||
@@ -955,7 +956,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// create new comment from dev user
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||
`{"text": "test 123",
|
||||
"user": {"name": "provider1_dev::good@example.com"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
@@ -976,7 +977,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
assert.Empty(t, mockDestination.Get()[0].Emails)
|
||||
|
||||
// create child comment from another user, email notification only to admin expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||
`{"text": "test 456",
|
||||
"pid": %q,
|
||||
"user": {"name": "other_user"},
|
||||
@@ -998,7 +999,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
// send confirmation token for email
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/subscribe",
|
||||
ts.URL+"/api/v1/email/subscribe?site=remark42",
|
||||
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -1037,7 +1038,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
// verify email
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/confirm",
|
||||
ts.URL+"/api/v1/email/confirm?site=remark42",
|
||||
io.NopCloser(strings.NewReader(fmt.Sprintf(`{"site": "remark42", "token": %q}`, verificationToken))),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -1069,7 +1070,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedUser)
|
||||
|
||||
// create child comment from another user, email notification expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||
`{"text": "test 789",
|
||||
"pid": %q,
|
||||
"user": {"name": "other_user"},
|
||||
@@ -1100,7 +1101,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
|
||||
// create child comment from another user, no email notification
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||
`{"text": "test 321",
|
||||
"user": {"name": "other_user"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
@@ -1158,7 +1159,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
// confirm email via subscribe call, no email notification is expected
|
||||
req, err = http.NewRequest(
|
||||
http.MethodPost,
|
||||
ts.URL+"/api/v1/email/subscribe",
|
||||
ts.URL+"/api/v1/email/subscribe?site=remark42",
|
||||
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -1205,7 +1206,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// create new comment from dev user
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||
`{"text": "test 123",
|
||||
"user": {"name": "provider1_dev::good@example.com"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
@@ -1226,7 +1227,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
assert.Empty(t, mockDestination.Get()[0].Telegrams)
|
||||
|
||||
// create child comment from another user, telegram notification only to admin expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||
`{"text": "test 456",
|
||||
"pid": %q,
|
||||
"user": {"name": "other_user"},
|
||||
@@ -1339,7 +1340,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
|
||||
|
||||
// create child comment from another user, telegram notification expected
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||
`{"text": "test 789",
|
||||
"pid": %q,
|
||||
"user": {"name": "other_user"},
|
||||
@@ -1370,7 +1371,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||
|
||||
// create child comment from another user, no telegram notification
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||
`{"text": "test 321",
|
||||
"user": {"name": "other_user"},
|
||||
"locator":{"url": "https://radio-t.com/blah1",
|
||||
@@ -1455,7 +1456,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
|
||||
c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
||||
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
|
||||
|
||||
for i := 0; i < 51; i++ {
|
||||
for i := range 51 {
|
||||
c.ID = fmt.Sprintf("id-%03d", i)
|
||||
c.Timestamp = c.Timestamp.Add(time.Second)
|
||||
_, err := srv.DataService.Create(c)
|
||||
@@ -1535,7 +1536,7 @@ func TestRest_SavePictureCtrl(t *testing.T) {
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture", ts.URL), bodyBuf)
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
@@ -1627,7 +1628,7 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
||||
require.NoError(t, bodyWriter.Close())
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture", ts.URL), bodyBuf)
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
@@ -1653,7 +1654,7 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
||||
text := fmt.Sprintf(`text 123  *xxx*  `, svc.RemarkURL, ids[0], svc.RemarkURL, ids[1], svc.RemarkURL, ids[2])
|
||||
body := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, text)
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment", body)
|
||||
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", body)
|
||||
assert.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
@@ -1690,3 +1691,26 @@ func (m *mockTelegram) CheckToken(string, string) (telegram, site string, err er
|
||||
}
|
||||
return "good_telegram", m.site, nil
|
||||
}
|
||||
|
||||
func TestExtractIP(t *testing.T) {
|
||||
tbl := []struct {
|
||||
addr string
|
||||
exp string
|
||||
}{
|
||||
{"127.0.0.1:8080", "127.0.0.1"},
|
||||
{"127.0.0.1", "127.0.0.1"},
|
||||
{"192.168.1.1:443", "192.168.1.1"},
|
||||
{"[::1]:8080", "::1"},
|
||||
{"::1", "::1"},
|
||||
{"[2001:db8::1]:8080", "2001:db8::1"},
|
||||
{"2001:db8::1", "2001:db8::1"},
|
||||
{"[fe80::1%25eth0]:80", "fe80::1%25eth0"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.addr, func(t *testing.T) {
|
||||
assert.Equal(t, tt.exp, extractIP(tt.addr))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
@@ -364,26 +365,81 @@ func (s *public) listCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// safePictureSegment reports whether seg is acceptable as a path segment in
|
||||
// the picture URL (no traversal markers, no path separators, no control
|
||||
// characters). Picture IDs are server-generated hashes plus a known
|
||||
// extension, so any value carrying these characters is hostile and must be
|
||||
// rejected before reaching the store. Rejecting controls (CR, LF, TAB, NUL,
|
||||
// etc.) also closes a log-injection vector since the rejected segment is
|
||||
// echoed into the access log.
|
||||
func safePictureSegment(seg string) bool {
|
||||
if seg == "" || seg == "." {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(seg, "/\\") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(seg, "..") { // also covers seg == ".."
|
||||
return false
|
||||
}
|
||||
for _, r := range seg {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sendPictureError writes a no-store Cache-Control header and delegates to rest.SendErrorJSON.
|
||||
// Used by every rejection branch in loadPictureCtrl so error responses never inherit the
|
||||
// 7-day client cache of the success path.
|
||||
func sendPictureError(w http.ResponseWriter, r *http.Request, status int, err error, details string, code int) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
rest.SendErrorJSON(w, r, status, err, details, code)
|
||||
}
|
||||
|
||||
// GET /picture/{user}/{id} - get picture
|
||||
func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "user") + "/" + chi.URLParam(r, "id")
|
||||
img, err := s.imageService.Load(id)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get image "+id, rest.ErrAssetNotFound)
|
||||
rest.SetImageDefenseHeaders(w)
|
||||
|
||||
user, imgID := chi.URLParam(r, "user"), chi.URLParam(r, "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)
|
||||
return
|
||||
}
|
||||
// enforce client-side caching
|
||||
id := user + "/" + imgID
|
||||
img, err := s.imageService.Load(id)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't load image %s: %v", id, err)
|
||||
sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("image not found"), "can't get image", rest.ErrAssetNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
contentType, err := rest.SafeImgContentType(img)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] rejecting non-image picture %s: %v", id, err)
|
||||
sendPictureError(w, r, http.StatusUnsupportedMediaType, err, "invalid image content", rest.ErrAssetNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// /picture/ does not need a security-version etag prefix — the upload flow
|
||||
// validates input format (readAndValidateImage) and the serve path re-validates
|
||||
// the stored bytes via rest.SafeImgContentType. Bytes within the resize dimension
|
||||
// limits ARE preserved verbatim by resize, so the browser defense relies on the
|
||||
// response headers (validated Content-Type + nosniff + strict CSP +
|
||||
// Content-Disposition: inline), not on byte normalization. Picture IDs are limited
|
||||
// to safePictureSegment (alphanumeric xid-generated guids), so the comma split
|
||||
// inside rest.EtagMatches cannot collide; if the ID format ever changes, revisit.
|
||||
etag := `"` + id + `"`
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
if strings.Contains(match, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && rest.EtagMatches(match, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", s.imageService.ImgContentType(img))
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(img)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err = io.Copy(w, bytes.NewReader(img)); err != nil {
|
||||
@@ -431,7 +487,7 @@ func (s *public) telegramQrCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
if _, err = w.Write(png); err != nil {
|
||||
if _, err = w.Write(png); err != nil { //nolint:gosec // png bytes from go-qrcode, not HTML
|
||||
log.Printf("[WARN] can't render qr, %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/google/uuid"
|
||||
@@ -19,6 +22,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
@@ -562,8 +566,8 @@ 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
|
||||
// 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
|
||||
ids := make([]string, 9)
|
||||
timestamps := make([]time.Time, 9)
|
||||
@@ -930,7 +934,7 @@ func TestRest_Config(t *testing.T) {
|
||||
err := json.Unmarshal([]byte(body), &j)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 300.0, j["edit_duration"])
|
||||
assert.EqualValues(t, []interface{}{"a1", "a2"}, j["admins"])
|
||||
assert.EqualValues(t, []any{"a1", "a2"}, j["admins"])
|
||||
assert.Equal(t, "admin@remark-42.com", j["admin_email"])
|
||||
assert.Equal(t, 4000.0, j["max_comment_size"])
|
||||
assert.Equal(t, -5.0, j["low_score"])
|
||||
@@ -1028,3 +1032,189 @@ func TestRest_Robots(t *testing.T) {
|
||||
"Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/user\nAllow: /api/v1/img\n"+
|
||||
"Allow: /api/v1/avatar\nAllow: /api/v1/picture\n", body)
|
||||
}
|
||||
|
||||
// TestRest_LoadPictureRejectsPathTraversal reproduces the unauthenticated path-traversal
|
||||
// vulnerability in GET /api/v1/picture/{user}/{id}. Before the fix, the handler concatenated
|
||||
// the URL params verbatim into a filesystem path via path.Join, so a request like
|
||||
// `/api/v1/picture/../remark.db` would resolve to `<base>/../remark.db`, escaping the image
|
||||
// directory. Even when the file did not exist (default Partitions=100 mitigates direct hits),
|
||||
// the FS error message leaked the constructed internal path back to the unauthenticated caller.
|
||||
func TestRest_LoadPictureRejectsPathTraversal(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{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"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+c.path, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.NotContains(t, s, "..", "error body must not echo traversal marker")
|
||||
assert.NotContains(t, s, "remark.db", "error body must not echo attacker-supplied filename")
|
||||
assert.NotContains(t, s, "no such file", "error body must not leak filesystem state")
|
||||
assert.NotContains(t, s, "/var/", "error body must not leak internal filesystem path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRest_LoadPictureRejectsControlCharsInSegment makes sure a CRLF / tab / NUL
|
||||
// in the URL segment is rejected by safePictureSegment. Without the rejection
|
||||
// the [WARN] log line constructed from %q-formatted segments would still be
|
||||
// safe (Go's %q escapes control chars), but a future log change to %s would
|
||||
// turn this into log forgery — and no legitimate picture id ever needs control
|
||||
// characters, so the right place to slam the door is in the validator.
|
||||
func TestRest_LoadPictureRejectsControlCharsInSegment(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "lf in user segment", path: "/api/v1/picture/dev%0Auser/abc.png"},
|
||||
{name: "cr in user segment", path: "/api/v1/picture/dev%0Duser/abc.png"},
|
||||
{name: "tab in user segment", path: "/api/v1/picture/dev%09user/abc.png"},
|
||||
{name: "lf in id segment", path: "/api/v1/picture/dev_user/abc%0A.png"},
|
||||
{name: "nul in id segment", path: "/api/v1/picture/dev_user/abc%00.png"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+c.path, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
s := string(body)
|
||||
assert.Contains(t, s, "invalid picture id", "must reject as invalid input, not fall through to storage")
|
||||
assert.NotContains(t, s, "no such file", "must not reach the filesystem")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRest_LoadPictureDefenseHeaders saves a real PNG via the standard upload handler
|
||||
// and asserts that GET /api/v1/picture/{user}/{id} carries the layered defense headers
|
||||
// (strict CSP, nosniff, Content-Disposition with filename) and that the strict ETag
|
||||
// matcher does not 304 on a substring-of-the-real-etag (the pre-fix matcher would).
|
||||
func TestRest_LoadPictureDefenseHeaders(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
// upload a real PNG via /api/v1/picture
|
||||
bodyBuf := &bytes.Buffer{}
|
||||
bodyWriter := multipart.NewWriter(bodyBuf)
|
||||
fileWriter, err := bodyWriter.CreateFormFile("file", "picture.png")
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(fileWriter, gopherPNG())
|
||||
require.NoError(t, err)
|
||||
contentType := bodyWriter.FormDataContentType()
|
||||
require.NoError(t, bodyWriter.Close())
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
m := map[string]string{}
|
||||
require.NoError(t, json.Unmarshal(body, &m))
|
||||
require.NotEmpty(t, m["id"])
|
||||
|
||||
// fetch the picture and assert defense headers
|
||||
resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||
resp.Header.Get("Content-Security-Policy"))
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, `inline; filename="image"`, resp.Header.Get("Content-Disposition"))
|
||||
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
|
||||
realEtag := resp.Header.Get("Etag")
|
||||
require.NotEmpty(t, realEtag)
|
||||
|
||||
// strict matcher: an If-None-Match value that CONTAINS the real etag as a substring
|
||||
// but is not equal to it must NOT trigger 304. The pre-fix matcher used
|
||||
// strings.Contains(header, etag) and would have returned true here.
|
||||
require.True(t, len(realEtag) > 4)
|
||||
substringMatch := "prefix-" + realEtag + "-suffix"
|
||||
req2, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req2.Header.Set("If-None-Match", substringMatch)
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode,
|
||||
"strict etag matcher must NOT 304 when real etag appears only as a substring of If-None-Match; got %q vs real %q", substringMatch, realEtag)
|
||||
|
||||
// sanity: the exact real etag DOES validate
|
||||
req3, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req3.Header.Set("If-None-Match", realEtag)
|
||||
resp3, err := client.Do(req3)
|
||||
require.NoError(t, err)
|
||||
defer resp3.Body.Close()
|
||||
assert.Equal(t, http.StatusNotModified, resp3.StatusCode, "exact etag must round-trip as 304")
|
||||
}
|
||||
|
||||
// TestRest_LoadPictureRejectsNonImage proves the /picture/ handler rejects bytes that
|
||||
// don't sniff as a real image — even when retrieved successfully from the image store.
|
||||
// Uses a StoreMock so we can return arbitrary attacker bytes for a valid-looking id.
|
||||
func TestRest_LoadPictureRejectsNonImage(t *testing.T) {
|
||||
htmlBody := []byte("<html><body><script>alert(document.domain)</script></body></html>")
|
||||
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||
return htmlBody, nil
|
||||
}}
|
||||
// minimal public struct on purpose: the reject path only exercises imageService.Load
|
||||
// (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)
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/api/v1/picture/dev_user/abc.png")
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode,
|
||||
"non-image bytes must be rejected as 415")
|
||||
assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"),
|
||||
"reject response must not be text/html; got %q", resp.Header.Get("Content-Type"))
|
||||
assert.NotContains(t, string(body), "<script>",
|
||||
"attacker payload must not be echoed back")
|
||||
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"),
|
||||
"rejection path must not be cacheable")
|
||||
// defense headers still present on the reject path
|
||||
assert.Equal(t, "default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||
resp.Header.Get("Content-Security-Policy"))
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, `inline; filename="image"`, resp.Header.Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ func TestRest_frameAncestors(t *testing.T) {
|
||||
o.AllowedAncestors = []string{"'self'", "https://example.com"}
|
||||
})
|
||||
|
||||
// Test case with frame-ancestors
|
||||
// test case with frame-ancestors
|
||||
client := http.Client{}
|
||||
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
@@ -329,7 +329,7 @@ func TestRest_frameAncestors(t *testing.T) {
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
|
||||
teardown()
|
||||
|
||||
// Test case without frame-ancestors
|
||||
// test case without frame-ancestors
|
||||
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||
srv.AllowedAncestors = []string{}
|
||||
})
|
||||
@@ -341,6 +341,44 @@ 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)
|
||||
@@ -352,6 +390,8 @@ func TestRest_securityHeaders(t *testing.T) {
|
||||
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
|
||||
@@ -364,6 +404,8 @@ func TestRest_securityHeaders(t *testing.T) {
|
||||
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) {
|
||||
@@ -433,7 +475,7 @@ func Test_validEmailAuth(t *testing.T) {
|
||||
|
||||
// randomPath pick a file or folder name which is not in use for sure
|
||||
func randomPath(tempDir, basename, suffix string) (string, error) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
|
||||
fmt.Printf("fname %q", fname)
|
||||
_, err := os.Stat(fname)
|
||||
@@ -626,7 +668,11 @@ func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
|
||||
postURL := ts.URL + "/api/v1/comment"
|
||||
if c.Locator.SiteID != "" {
|
||||
postURL += "?site=" + c.Locator.SiteID
|
||||
}
|
||||
req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(b))
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("X-JWT", devToken)
|
||||
resp, err := client.Do(req)
|
||||
@@ -663,7 +709,7 @@ func requireAdminOnly(t *testing.T, req *http.Request) {
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for i := 0; i < 10; i++ {
|
||||
for range 10 {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
||||
_ = ln.Close()
|
||||
@@ -675,7 +721,7 @@ func chooseRandomUnusedPort() (port int) {
|
||||
|
||||
func waitForHTTPSServerStart(port int) {
|
||||
// wait for up to 3 seconds for HTTPS server to start
|
||||
for i := 0; i < 300; i++ {
|
||||
for range 300 {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
||||
if conn != nil {
|
||||
@@ -692,3 +738,43 @@ func TestMain(m *testing.M) {
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func (s *rss) postCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err = w.Write(data); err != nil {
|
||||
if _, err = w.Write(data); err != nil { //nolint:gosec // xml feed bytes from gorilla/feeds, not HTML
|
||||
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func (s *rss) siteCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err = w.Write(data); err != nil {
|
||||
if _, err = w.Write(data); err != nil { //nolint:gosec // xml feed bytes from gorilla/feeds, not HTML
|
||||
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func (s *rss) repliesCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err = w.Write(data); err != nil {
|
||||
if _, err = w.Write(data); err != nil { //nolint:gosec // xml feed bytes from gorilla/feeds, not HTML
|
||||
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ type errTmplData struct {
|
||||
// error code is not included in render as it is intended for UI developers and not for the users
|
||||
func SendErrorHTML(w http.ResponseWriter, r *http.Request, httpStatusCode int, err error, details string, errCode int) {
|
||||
// MustExecute behaves like template.Execute, but panics if an error occurs.
|
||||
MustExecute := func(tmpl *template.Template, wr io.Writer, data interface{}) {
|
||||
MustExecute := func(tmpl *template.Template, wr io.Writer, data any) {
|
||||
if err = tmpl.Execute(wr, data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StrictImageCSP is the strictest default-deny Content-Security-Policy used both by
|
||||
// image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) and by the api-wide
|
||||
// apiCSPMiddleware (covering all /api/v1/* responses — JSON, XML/RSS, images). The name
|
||||
// keeps the "image" prefix for historical reasons; the policy itself is generic and
|
||||
// suitable for any non-document API response.
|
||||
//
|
||||
// Re-setting the same value inside the image handlers (after the middleware already set
|
||||
// it) is intentional defense-in-depth: if the middleware ever stops applying (route
|
||||
// refactor, mount point change), the handlers still emit the header.
|
||||
const StrictImageCSP = "default-src 'none'; sandbox; frame-ancestors 'none'"
|
||||
|
||||
// SafeImgContentType returns the sniffed content type for provided bytes if and only
|
||||
// if it is in the strict allowlist of image formats safe to serve from a same-origin
|
||||
// proxy endpoint: image/png, image/jpeg, image/gif, image/webp, image/bmp, image/x-icon.
|
||||
// Anything else — HTML, XML, SVG, plain text, application/octet-stream, or any future
|
||||
// image format the stdlib sniffer may learn (e.g. AVIF, HEIC, JXL, TIFF) — is rejected.
|
||||
// SVG would also be rejected as it sniffs as text/xml or text/plain, never image/svg+xml.
|
||||
// The previous behavior silently mapped application/octet-stream to image/* and is gone.
|
||||
func SafeImgContentType(img []byte) (string, error) {
|
||||
contentType := http.DetectContentType(img)
|
||||
base, _, _ := strings.Cut(contentType, ";")
|
||||
base = strings.TrimSpace(base)
|
||||
switch base {
|
||||
case "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-icon":
|
||||
return base, nil
|
||||
}
|
||||
return "", fmt.Errorf("non-image content type %q", contentType)
|
||||
}
|
||||
|
||||
// SetImageDefenseHeaders applies the layered defense headers shared by every response
|
||||
// from image-serving endpoints (success, 304, or error). Each header survives content-type
|
||||
// validation regressions, browser sniffing, and top-level navigation:
|
||||
// - Content-Security-Policy: strict, with sandbox — blocks inline scripts and event handlers
|
||||
// - X-Content-Type-Options: nosniff — prevents browsers from MIME-overriding the declared type
|
||||
// - Content-Disposition: inline; filename="image" — frames the response as a file, not a document
|
||||
//
|
||||
// CSP is duplicated by apiCSPMiddleware for /api/v1/* — re-setting the same value here is
|
||||
// harmless and provides defense-in-depth if the middleware is bypassed or moved. The other
|
||||
// two headers (nosniff, Content-Disposition with filename) are image-specific and not set
|
||||
// by the middleware.
|
||||
func SetImageDefenseHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Security-Policy", StrictImageCSP)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="image"`)
|
||||
}
|
||||
|
||||
// EtagMatches reports whether If-None-Match header value contains the given etag.
|
||||
// Handles the * wildcard, comma-separated etag lists with the W/ weak-validator prefix.
|
||||
// NOTE: This is intentionally a simple splitter — it does not handle opaque-tags that
|
||||
// contain commas (allowed by RFC 7232 but never emitted by this codebase, whose etag
|
||||
// format is `"v2:<base64-url>"` or `"<user>/<xid>"`). If the etag format ever changes
|
||||
// to include comma-bearing values, revisit this parser.
|
||||
// Replaces a substring search that could match unrelated entries (e.g. an etag that
|
||||
// happens to be a prefix of another).
|
||||
func EtagMatches(header, etag string) bool {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "*" {
|
||||
return true
|
||||
}
|
||||
for tag := range strings.SplitSeq(header, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
tag = strings.TrimPrefix(tag, "W/")
|
||||
if tag == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestEtagMatches covers the strict If-None-Match parser that replaced a substring
|
||||
// search prone to false positives (etag "abc" being matched inside "fooabc").
|
||||
func TestEtagMatches(t *testing.T) {
|
||||
tbl := []struct {
|
||||
name string
|
||||
header string
|
||||
etag string
|
||||
want bool
|
||||
}{
|
||||
{"exact match", `"v2:abc"`, `"v2:abc"`, true},
|
||||
{"comma-separated, second matches", `"x", "v2:abc"`, `"v2:abc"`, true},
|
||||
{"weak validator prefix", `W/"v2:abc"`, `"v2:abc"`, true},
|
||||
{"wildcard matches anything", `*`, `"v2:abc"`, true},
|
||||
{"leading/trailing whitespace", ` "v2:abc" `, `"v2:abc"`, true},
|
||||
{"substring not enough", `"v2:abcdef"`, `"v2:abc"`, false},
|
||||
{"prefix-only mismatch", `"v2:ab"`, `"v2:abc"`, false},
|
||||
{"pre-fix etag no longer matches v2", `"abc"`, `"v2:abc"`, false},
|
||||
{"empty header", ``, `"v2:abc"`, false},
|
||||
{"different etag", `"v2:xyz"`, `"v2:abc"`, false},
|
||||
}
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, EtagMatches(tt.header, tt.etag))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetImageDefenseHeaders(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SetImageDefenseHeaders(w)
|
||||
assert.Equal(t, StrictImageCSP, w.Header().Get("Content-Security-Policy"))
|
||||
assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, `inline; filename="image"`, w.Header().Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
// TestSafeImgContentType exercises the strict allowlist. The previous behavior
|
||||
// (HasPrefix "image/" with an explicit image/svg+xml carve-out) is gone — the
|
||||
// allowlist is the source of truth, and the explicit svg branch was dead code
|
||||
// because http.DetectContentType never returns image/svg+xml (real SVG bodies
|
||||
// sniff as text/xml or text/plain depending on whether they carry an XML decl,
|
||||
// so they are rejected implicitly by not matching the allowlist).
|
||||
func TestSafeImgContentType(t *testing.T) {
|
||||
// minimal magic-byte bodies — verified via http.DetectContentType to produce
|
||||
// the expected image/* result without needing testdata files for every format
|
||||
pngMagic := []byte("\x89PNG\r\n\x1a\n")
|
||||
jpegMagic := []byte("\xff\xd8\xff\xe0\x00\x10JFIF\x00")
|
||||
gifBytes := []byte("GIF89a")
|
||||
webpBytes := []byte("RIFF\x00\x00\x00\x00WEBPVP8 ")
|
||||
bmpBytes := []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")
|
||||
icoBytes := []byte("\x00\x00\x01\x00\x01\x00")
|
||||
|
||||
// SVG with XML decl sniffs as text/xml — rejected because it's not in the allowlist
|
||||
svgWithXMLDecl := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`)
|
||||
// SVG without XML decl sniffs as text/plain — also rejected
|
||||
svgPlain := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="10"></svg>`)
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
body []byte
|
||||
wantCT string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "nil rejected", body: nil, wantErr: true},
|
||||
{name: "empty rejected", body: []byte{}, wantErr: true},
|
||||
{name: "png magic accepted", body: pngMagic, wantCT: "image/png"},
|
||||
{name: "jpeg magic accepted", body: jpegMagic, wantCT: "image/jpeg"},
|
||||
{name: "gif accepted", body: gifBytes, wantCT: "image/gif"},
|
||||
{name: "webp accepted", body: webpBytes, wantCT: "image/webp"},
|
||||
{name: "bmp accepted", body: bmpBytes, wantCT: "image/bmp"},
|
||||
{name: "ico accepted", body: icoBytes, wantCT: "image/x-icon"},
|
||||
{name: "html doc rejected", body: []byte(`<!DOCTYPE html><html></html>`), wantErr: true},
|
||||
{name: "html fragment rejected", body: []byte(`<body><img></body>`), wantErr: true},
|
||||
{name: "plain text rejected", body: []byte("hello world"), wantErr: true},
|
||||
{name: "octet-stream rejected", body: []byte{0x00, 0x01, 0x02, 0x03, 0x04}, wantErr: true},
|
||||
{name: "svg with xml decl rejected (sniffs as text/xml)", body: svgWithXMLDecl, wantErr: true},
|
||||
{name: "svg without xml decl rejected (sniffs as text/plain)", body: svgPlain, wantErr: true},
|
||||
}
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := SafeImgContentType(tt.body)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, got)
|
||||
assert.Contains(t, err.Error(), "non-image content type")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantCT, got)
|
||||
// returned type must never carry a charset suffix (the strip code path)
|
||||
assert.NotContains(t, got, ";")
|
||||
})
|
||||
}
|
||||
}
|
||||
+104
-25
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,9 +16,15 @@ import (
|
||||
"github.com/go-pkgz/repeater/v2"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/safehttp"
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
)
|
||||
|
||||
// errInvalidUpstreamContentType is returned by downloadImage when the upstream's
|
||||
// Content-Type header is not image/*. The handler checks via errors.Is to convert
|
||||
// it into a 400 (input rejected) instead of the generic 404 (fetch failed).
|
||||
var errInvalidUpstreamContentType = errors.New("invalid upstream content type")
|
||||
|
||||
// Image extracts image src from comment's html and provides proxy for them
|
||||
// this is needed to keep remark42 running behind of HTTPS serve all images via https
|
||||
type Image struct {
|
||||
@@ -27,6 +34,11 @@ type Image struct {
|
||||
CacheExternal bool
|
||||
Timeout time.Duration
|
||||
ImageService *image.Service
|
||||
// Transport, if non-nil, is used as-is for outbound image fetches and is the
|
||||
// caller's responsibility to make SSRF-safe. When nil, safehttp.Transport()
|
||||
// is installed, which blocks dialing any private/reserved IP and resolves
|
||||
// hostnames to defeat DNS rebinding.
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
// Convert img src links to proxied links depends on enabled options
|
||||
@@ -78,31 +90,67 @@ func (p Image) replace(commentHTML string, imgs []string) string {
|
||||
return commentHTML
|
||||
}
|
||||
|
||||
// etagVersionPrefix is the security-version tag bumped whenever cached responses for the
|
||||
// same src need to be invalidated. Pre-fix responses were served as text/html and cached
|
||||
// by browsers/proxies under ETag `"<base64(src)>"`; the prefix invalidates those validators
|
||||
// so revalidating clients get a fresh 200 instead of letting the cached HTML 304.
|
||||
//
|
||||
// LIMITATION: with the 30-day max-age below, browsers serve pre-fix bytes from their
|
||||
// local cache without contacting the server until that TTL expires or the cache is
|
||||
// evicted under memory pressure. The prefix only helps clients that revalidate during
|
||||
// the cached lifetime (Ctrl+R, intermediaries, post-expiry use). Operators running a
|
||||
// CDN/edge cache in front of remark42 should purge /api/v1/img after deploy. The
|
||||
// realistic exposure is narrow: cache carryover only affects users who navigated
|
||||
// top-level to an attacker URL pre-fix and still have that URL cached — the normal
|
||||
// <img> embed path cached text/html but never executed it.
|
||||
const etagVersionPrefix = "v2:"
|
||||
|
||||
// Handler returns http handler respond to proxied request
|
||||
func (p Image) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
src, err := base64.URLEncoding.DecodeString(r.URL.Query().Get("src"))
|
||||
rest.SetImageDefenseHeaders(w)
|
||||
|
||||
srcParam := r.URL.Query().Get("src")
|
||||
src, err := base64.URLEncoding.DecodeString(srcParam)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't decode image url", rest.ErrDecode)
|
||||
sendImageProxyError(w, r, http.StatusBadRequest, err, "can't decode image url", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
|
||||
imgURL := string(src)
|
||||
var img []byte
|
||||
imgID, err := image.CachedImgID(imgURL)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse image url "+imgURL, rest.ErrAssetNotFound)
|
||||
sendImageProxyError(w, r, http.StatusBadRequest, fmt.Errorf("invalid image url"), "can't parse image url", rest.ErrAssetNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// compute the current-version etag once. We don't set it as a response header yet
|
||||
// because error paths below must NOT inherit it — otherwise transient failures
|
||||
// (4xx) would get cached alongside the 30-day Cache-Control of the success path.
|
||||
// The etag (and Cache-Control) are set only on the 304 short-circuit and the
|
||||
// validated 200 path.
|
||||
etag := `"` + etagVersionPrefix + srcParam + `"`
|
||||
// short-circuit revalidation before any cache lookup or upstream fetch: a matching
|
||||
// current-version If-None-Match means the client already has bytes from a prior
|
||||
// successful (post-fix, validated) 200, so a bodyless 304 is safe and avoids
|
||||
// upstream DoS amplification on hot comment pages without CacheExternal.
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && rest.EtagMatches(match, etag) {
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=2592000") // 30 days
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// try to load from cache for case it was saved when CacheExternal was enabled
|
||||
img, _ = p.ImageService.Load(imgID)
|
||||
img, _ := p.ImageService.Load(imgID)
|
||||
if img == nil {
|
||||
img, err = p.downloadImage(context.Background(), imgURL)
|
||||
img, err = p.downloadImage(r.Context(), imgURL)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid content type") {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid content type", rest.ErrImgNotFound)
|
||||
log.Printf("[WARN] failed to download image: %v", err)
|
||||
if errors.Is(err, errInvalidUpstreamContentType) {
|
||||
sendImageProxyError(w, r, http.StatusBadRequest, fmt.Errorf("invalid content type"), "invalid content type", rest.ErrImgNotFound)
|
||||
return
|
||||
}
|
||||
rest.SendErrorJSON(w, r, http.StatusNotFound, err, "can't get image "+imgURL, rest.ErrAssetNotFound)
|
||||
sendImageProxyError(w, r, http.StatusNotFound, fmt.Errorf("failed to fetch"), "can't get image", rest.ErrAssetNotFound)
|
||||
return
|
||||
}
|
||||
if p.CacheExternal {
|
||||
@@ -110,24 +158,37 @@ func (p Image) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// enforce client-side caching
|
||||
etag := `"` + r.URL.Query().Get("src") + `"`
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=2592000") // 30 days
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
if strings.Contains(match, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
// validate body bytes are actually an image — never trust upstream Content-Type or cache
|
||||
contentType, err := rest.SafeImgContentType(img)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] rejecting non-image content from %s: %v", imgURL, err)
|
||||
sendImageProxyError(w, r, http.StatusUnsupportedMediaType, err, "invalid image content", rest.ErrImgNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", p.ImageService.ImgContentType(img))
|
||||
// success path: long-lived client cache with etag for cheap revalidation. 30-day
|
||||
// TTL keeps the proxy efficient for hot pages; when clients DO revalidate
|
||||
// (Ctrl+R, intermediaries, post-expiry), the versioned etag ensures pre-fix
|
||||
// poisoned validators don't match and a fresh validated 200 is returned. See
|
||||
// etagVersionPrefix godoc for the limitation on browser-local caches.
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=2592000") // 30 days
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
_, err = io.Copy(w, bytes.NewReader(img))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't copy image stream, %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendImageProxyError writes a no-store error response so a transient failure (4xx)
|
||||
// cannot inherit the success path's 30-day Cache-Control or the versioned ETag, which
|
||||
// would otherwise pin the error in the browser/intermediary cache for that TTL.
|
||||
// Defense headers from SetImageDefenseHeaders at the top of the handler survive.
|
||||
func sendImageProxyError(w http.ResponseWriter, r *http.Request, status int, err error, details string, errCode int) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
rest.SendErrorJSON(w, r, status, err, details, errCode)
|
||||
}
|
||||
|
||||
// cache image from provided Reader using given ID
|
||||
func (p Image) cacheImage(r io.Reader, imgID string) {
|
||||
err := p.ImageService.SaveWithID(imgID, r)
|
||||
@@ -148,16 +209,26 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
client := http.Client{Timeout: 30 * time.Second}
|
||||
transport := p.Transport
|
||||
if transport == nil {
|
||||
transport = safehttp.Transport()
|
||||
}
|
||||
client := http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
var resp *http.Response
|
||||
err := repeater.NewFixed(5, time.Second).Do(ctx, func() error {
|
||||
var e error
|
||||
req, e := http.NewRequest("GET", imgURL, http.NoBody)
|
||||
// SSRF safety: client.Transport is safehttp.Transport() when p.Transport is nil
|
||||
// (see Image.Transport contract above); when caller supplies a transport they
|
||||
// own SSRF safety for that path.
|
||||
req, e := http.NewRequest("GET", imgURL, http.NoBody) //nolint:gosec // see comment above
|
||||
if e != nil {
|
||||
return fmt.Errorf("failed to make request for %s: %w", imgURL, e)
|
||||
}
|
||||
resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose // need a refactor to fix that
|
||||
resp, e = client.Do(req.WithContext(ctx)) //nolint:bodyclose,gosec // body closed in defer; transport contract above
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
@@ -171,12 +242,20 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
return nil, fmt.Errorf("invalid content type %s", contentType)
|
||||
return nil, fmt.Errorf("%w: %s", errInvalidUpstreamContentType, contentType)
|
||||
}
|
||||
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
maxSize := 5 * 1024 * 1024 // 5MB default
|
||||
if p.ImageService != nil && p.ImageService.MaxSize > 0 {
|
||||
maxSize = p.ImageService.MaxSize
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, int64(maxSize)+1)
|
||||
imgData, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to read image body")
|
||||
return nil, fmt.Errorf("unable to read image body: %w", err)
|
||||
}
|
||||
if len(imgData) > maxSize {
|
||||
return nil, fmt.Errorf("image is too large")
|
||||
}
|
||||
return imgData, nil
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ func TestImage_Routes(t *testing.T) {
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
@@ -150,6 +151,7 @@ func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) {
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
@@ -183,6 +185,7 @@ func TestImage_RoutesCachingImage(t *testing.T) {
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1500}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
@@ -207,34 +210,59 @@ func TestImage_RoutesCachingImage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImage_RoutesUsingCachedImage(t *testing.T) {
|
||||
// In order to validate that cached data used cache "will return" some other data from what http server would
|
||||
testImage := []byte(fmt.Sprintf("%256s", "X"))
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||
return testImage, nil
|
||||
}}
|
||||
img := Image{
|
||||
CacheExternal: true,
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
}
|
||||
t.Run("cached image is served", func(t *testing.T) {
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||
return gopherPNGBytes(), nil
|
||||
}}
|
||||
img := Image{
|
||||
CacheExternal: true,
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
httpSrv := imgHTTPTestsServer(t)
|
||||
defer httpSrv.Close()
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
httpSrv := imgHTTPTestsServer(t)
|
||||
defer httpSrv.Close()
|
||||
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
|
||||
assert.Equal(t, 1, len(imageStore.LoadCalls()))
|
||||
})
|
||||
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "256", resp.Header["Content-Length"][0])
|
||||
assert.Equal(t, "text/plain; charset=utf-8", resp.Header["Content-Type"][0],
|
||||
"if you save text you receive text/plain in response, that's only fair option you got")
|
||||
t.Run("non-image cached bytes are rejected (cache poisoning defense)", func(t *testing.T) {
|
||||
nonImage := fmt.Appendf(nil, "%256s", "X")
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||
return nonImage, nil
|
||||
}}
|
||||
img := Image{
|
||||
CacheExternal: true,
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
}
|
||||
|
||||
assert.Equal(t, 1, len(imageStore.LoadCalls()))
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
httpSrv := imgHTTPTestsServer(t)
|
||||
defer httpSrv.Close()
|
||||
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode,
|
||||
"non-image bytes from cache must be rejected, not served as text/plain (XSS defense)")
|
||||
assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"),
|
||||
"reject response must not be text/html; got %q", resp.Header.Get("Content-Type"))
|
||||
assert.NotContains(t, string(body), "XXXXX", "non-image bytes must not be echoed back")
|
||||
})
|
||||
}
|
||||
|
||||
func TestImage_RoutesTimedOut(t *testing.T) {
|
||||
@@ -246,6 +274,7 @@ func TestImage_RoutesTimedOut(t *testing.T) {
|
||||
RoutePath: "/api/v1/proxy",
|
||||
Timeout: 50 * time.Millisecond,
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
@@ -262,7 +291,8 @@ func TestImage_RoutesTimedOut(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
require.NoError(t, err)
|
||||
t.Log(string(b))
|
||||
assert.Contains(t, string(b), "deadline exceeded")
|
||||
assert.Contains(t, string(b), "failed to fetch")
|
||||
assert.NotContains(t, string(b), "deadline exceeded", "should not leak transport details")
|
||||
assert.Equal(t, 1, len(imageStore.LoadCalls()))
|
||||
}
|
||||
|
||||
@@ -304,6 +334,474 @@ func TestImage_ConvertCachingMode(t *testing.T) {
|
||||
assert.Equal(t, `<img src="https://remark42.com/img?src=aHR0cDovL3JhZGlvLXQuY29tL2ltZzMucG5n"/> xyz <img src="https://remark42.com/img?src=aHR0cDovL2ltYWdlcy5wZXhlbHMuY29tLzY3NjM2L2ltZzQuanBlZw==">`, r)
|
||||
}
|
||||
|
||||
func TestImage_PrivateIPBlocking(t *testing.T) {
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
HTTP2HTTPS: true,
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
// no Transport override — uses SSRF-safe transport
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
url string
|
||||
}{
|
||||
{"loopback", "http://127.0.0.1/image.png"},
|
||||
{"rfc1918 10.x", "http://10.0.0.1/image.png"},
|
||||
{"rfc1918 172.16.x", "http://172.16.0.1/image.png"},
|
||||
{"rfc1918 192.168.x", "http://192.168.1.1/image.png"},
|
||||
{"link-local", "http://169.254.1.1/image.png"},
|
||||
{"ipv6 loopback", "http://[::1]/image.png"},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(tt.url))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
assert.NotContains(t, string(b), "private address", "should not leak private IP check details")
|
||||
assert.Contains(t, string(b), "failed to fetch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImage_ErrorSanitization(t *testing.T) {
|
||||
// server that immediately closes connections to simulate transport errors
|
||||
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
conn, _, _ := hj.Hijack()
|
||||
conn.Close() // forcefully close to trigger transport error
|
||||
}))
|
||||
defer httpSrv.Close()
|
||||
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
Timeout: 2 * time.Second,
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
assert.Contains(t, string(b), "failed to fetch")
|
||||
assert.NotContains(t, string(b), "EOF", "should not leak transport details")
|
||||
assert.NotContains(t, string(b), "connection", "should not leak transport details")
|
||||
}
|
||||
|
||||
func TestImage_ResponseSizeLimit(t *testing.T) {
|
||||
// create a test server that returns a large image
|
||||
largeImg := make([]byte, 2000)
|
||||
for i := range largeImg {
|
||||
largeImg[i] = 0xFF
|
||||
}
|
||||
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(largeImg)
|
||||
}))
|
||||
defer httpSrv.Close()
|
||||
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{MaxSize: 1000}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/big-image.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
assert.Contains(t, string(b), "failed to fetch")
|
||||
}
|
||||
|
||||
// TestImage_ContentTypeHandling covers both the rock-solid acceptance of legitimate
|
||||
// images and the rejection of content-type-spoofing payloads (the XSS vector where
|
||||
// upstream lies about Content-Type and the proxy serves attacker HTML back from the
|
||||
// remark42 origin). Every response — accept or reject — must carry the layered
|
||||
// defense headers (strict CSP, nosniff, Content-Disposition: inline).
|
||||
//
|
||||
// The defense must not depend on the upstream Content-Type header: each row controls
|
||||
// it independently of the body so the matrix exercises attackers who flip the upstream
|
||||
// header on the fly, and polyglot bodies where image magic bytes prefix HTML payloads.
|
||||
func TestImage_ContentTypeHandling(t *testing.T) {
|
||||
htmlBody := []byte("<html><body><script>alert(document.domain)</script></body></html>")
|
||||
// polyglot: real PNG magic + trailing HTML. Sniffs as image/png, must be served
|
||||
// as image/png so the browser renders as image (broken or otherwise) — never as HTML.
|
||||
polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("<script>alert(1)</script>")...)
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
upstreamCT string // Content-Type header the upstream sends
|
||||
body []byte
|
||||
accept bool // true: legitimate image, served back; false: attack, rejected
|
||||
wantCT string // exact Content-Type if accept
|
||||
payloadMarker string // attack substring that must NOT appear in the response body
|
||||
}{
|
||||
// legitimate
|
||||
{name: "real png", upstreamCT: "image/png", body: gopherPNGBytes(), accept: true, wantCT: "image/png"},
|
||||
|
||||
// upstream lies — body is HTML, header varies. All must be rejected at body-sniff.
|
||||
{name: "html body claimed as image/png", upstreamCT: "image/png", body: htmlBody, payloadMarker: "<script>"},
|
||||
{name: "html body claimed as image/jpeg", upstreamCT: "image/jpeg", body: htmlBody, payloadMarker: "<script>"},
|
||||
{name: "html body claimed as image/gif", upstreamCT: "image/gif", body: htmlBody, payloadMarker: "<script>"},
|
||||
// upstream claims svg+xml; body still sniffs as text/html (the stdlib sniffer
|
||||
// never returns image/svg+xml, see rest.SafeImgContentType godoc).
|
||||
{name: "html body upstream claims image/svg+xml", upstreamCT: "image/svg+xml", body: htmlBody, payloadMarker: "<script>"},
|
||||
{name: "html body claimed as image/webp", upstreamCT: "image/webp", body: htmlBody, payloadMarker: "<script>"},
|
||||
|
||||
// svg payloads — even if upstream claims a valid image format, the sniffer sees XML/text and we must reject
|
||||
{
|
||||
name: "svg with xml declaration and onload",
|
||||
upstreamCT: "image/png",
|
||||
body: []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"></svg>`),
|
||||
payloadMarker: "onload",
|
||||
},
|
||||
{
|
||||
name: "html fragment without doctype",
|
||||
upstreamCT: "image/png",
|
||||
body: []byte(`<body><img src=x onerror=alert(1)></body>`),
|
||||
payloadMarker: "onerror",
|
||||
},
|
||||
|
||||
// polyglot — image magic + appended HTML. Sniffs as image/png so we accept and serve as image/png.
|
||||
// Safety comes from the response headers (Content-Type: image/png + X-Content-Type-Options: nosniff),
|
||||
// not from body filtering: the bytes round-trip verbatim by design (assertion below). The browser
|
||||
// cannot execute the trailing HTML when the response type is image/png with nosniff.
|
||||
{name: "polyglot png+html served as png", upstreamCT: "image/png", body: polyglot, accept: true, wantCT: "image/png"},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", tt.upstreamCT)
|
||||
_, _ = w.Write(tt.body)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte(upstream.URL + "/logo.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedURL)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
// every response — accept or reject — must carry the defense headers
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp, "default-src 'none'", "strict CSP missing")
|
||||
assert.Contains(t, csp, "sandbox", "CSP sandbox missing")
|
||||
|
||||
if tt.accept {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, tt.wantCT, resp.Header.Get("Content-Type"))
|
||||
assert.Equal(t, tt.body, body, "body bytes must round-trip")
|
||||
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age=2592000",
|
||||
"validated success path carries the 30-day TTL")
|
||||
assert.True(t, strings.HasPrefix(resp.Header.Get("Etag"), `"v2:`),
|
||||
"validated success path carries the versioned etag")
|
||||
return
|
||||
}
|
||||
|
||||
// reject path
|
||||
assert.GreaterOrEqual(t, resp.StatusCode, 400, "must reject non-image content")
|
||||
// reject responses must NOT inherit the success path's long-lived cache
|
||||
// headers — a transient 4xx would otherwise be pinned in browser/intermediary
|
||||
// caches alongside the versioned etag for 30 days.
|
||||
assert.Contains(t, resp.Header.Get("Cache-Control"), "no-store",
|
||||
"reject path must set Cache-Control: no-store; got %q", resp.Header.Get("Cache-Control"))
|
||||
assert.NotContains(t, resp.Header.Get("Cache-Control"), "max-age=2592000",
|
||||
"reject path must not carry the success-path 30-day TTL")
|
||||
assert.Empty(t, resp.Header.Get("Etag"),
|
||||
"reject path must not carry the versioned etag (would pin the failure in cache)")
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
assert.False(t, strings.HasPrefix(ct, "text/html"),
|
||||
"reject response must not be text/html; got %q", ct)
|
||||
assert.NotContains(t, string(body), tt.payloadMarker,
|
||||
"reject response must not echo attack payload; got body=%q", string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEtagMatches lives in the rest package alongside the shared EtagMatches helper
|
||||
// (see backend/app/rest/image_headers_test.go). The proxy handler delegates to it.
|
||||
|
||||
// TestImage_ContentTypeHandling_CacheHit exercises the cache-hit branch of the handler:
|
||||
// the StoreMock returns attacker bytes directly, so the upstream is never contacted.
|
||||
// Without the body-sniff at serve time, pre-fix code would have echoed cached HTML as
|
||||
// text/html. After the fix the same content-type defense applies on the cache path.
|
||||
func TestImage_ContentTypeHandling_CacheHit(t *testing.T) {
|
||||
htmlBody := []byte("<html><body><script>alert(document.domain)</script></body></html>")
|
||||
polyglot := append(append([]byte{}, gopherPNGBytes()...), []byte("<script>alert(1)</script>")...)
|
||||
|
||||
tbl := []struct {
|
||||
name string
|
||||
cached []byte
|
||||
accept bool
|
||||
wantCT string
|
||||
payloadMarker string
|
||||
}{
|
||||
{name: "html in cache claimed as image/png", cached: htmlBody, payloadMarker: "<script>"},
|
||||
{name: "polyglot in cache served as png", cached: polyglot, accept: true, wantCT: "image/png"},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||
return tt.cached, nil
|
||||
}}
|
||||
img := Image{
|
||||
CacheExternal: true,
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
// no Transport — cache hit must not reach upstream
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte("https://attacker.example.com/logo.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedURL)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.Equal(t, 1, len(imageStore.LoadCalls()), "served from cache")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"),
|
||||
"default-src 'none'; sandbox; frame-ancestors 'none'")
|
||||
|
||||
if tt.accept {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, tt.wantCT, resp.Header.Get("Content-Type"))
|
||||
return
|
||||
}
|
||||
assert.GreaterOrEqual(t, resp.StatusCode, 400, "must reject non-image cached content")
|
||||
assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"),
|
||||
"reject response must not be text/html; got %q", resp.Header.Get("Content-Type"))
|
||||
assert.NotContains(t, string(body), tt.payloadMarker,
|
||||
"reject response must not echo cached attack payload")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImage_EtagVersioned proves browser/proxy caches with pre-fix etags (the
|
||||
// unversioned base64 of src that used to be served alongside text/html bodies)
|
||||
// no longer satisfy revalidation: the server returns a fresh 200 with image
|
||||
// content instead of 304-ing the poisoned cached entry. The 30-day Cache-Control
|
||||
// max-age is unchanged — local browser caches still serving pre-fix bytes within
|
||||
// their TTL are not reached; the prefix only helps clients that revalidate during
|
||||
// the cached lifetime (Ctrl+R, intermediaries, post-expiry). See etagVersionPrefix
|
||||
// godoc for the tradeoff.
|
||||
func TestImage_EtagVersioned(t *testing.T) {
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
httpSrv := imgHTTPTestsServer(t)
|
||||
defer httpSrv.Close()
|
||||
|
||||
srcRaw := httpSrv.URL + "/image/img1.png"
|
||||
encodedSrc := base64.URLEncoding.EncodeToString([]byte(srcRaw))
|
||||
preFixEtag := `"` + encodedSrc + `"` // what a pre-fix browser would have cached
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL+"/?src="+encodedSrc, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("If-None-Match", preFixEtag)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode,
|
||||
"pre-fix etag must NOT validate as 304 — old cached text/html must be replaced")
|
||||
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
|
||||
assert.NotEqual(t, preFixEtag, resp.Header.Get("Etag"), "new etag must differ from pre-fix")
|
||||
assert.True(t, strings.HasPrefix(resp.Header.Get("Etag"), `"v2:`), "new etag must carry the version prefix")
|
||||
cc := resp.Header.Get("Cache-Control")
|
||||
assert.Contains(t, cc, "max-age=2592000", "success path keeps 30-day TTL for cache efficiency")
|
||||
|
||||
// sanity: the NEW etag round-trips as 304 when sent back
|
||||
loadsBefore := len(imageStore.LoadCalls())
|
||||
req2, err := http.NewRequest("GET", ts.URL+"/?src="+encodedSrc, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req2.Header.Set("If-None-Match", resp.Header.Get("Etag"))
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
assert.Equal(t, http.StatusNotModified, resp2.StatusCode, "new etag must validate against itself")
|
||||
body, _ := io.ReadAll(resp2.Body)
|
||||
assert.Empty(t, body, "304 must have no body")
|
||||
// 304 path must skip the store lookup entirely — revalidation must not amplify load
|
||||
assert.Equal(t, loadsBefore, len(imageStore.LoadCalls()),
|
||||
"revalidation 304 must not trigger any store Load (avoids upstream DoS amplification)")
|
||||
// 304 path must still carry the layered defense headers
|
||||
assert.Equal(t, "nosniff", resp2.Header.Get("X-Content-Type-Options"))
|
||||
assert.Contains(t, resp2.Header.Get("Content-Disposition"), "inline")
|
||||
assert.Contains(t, resp2.Header.Get("Content-Security-Policy"), "default-src 'none'")
|
||||
assert.Contains(t, resp2.Header.Get("Content-Security-Policy"), "sandbox")
|
||||
}
|
||||
|
||||
// TestImage_RevalidationSkipsIO proves that a matching current-version If-None-Match
|
||||
// short-circuits before any cache lookup or upstream fetch. With no Transport and no
|
||||
// upstream server reachable, the only way this test can pass with 304 is if Load is
|
||||
// never called and downloadImage is never attempted. This closes the DoS amplification
|
||||
// where every reuse on a hot comment page would otherwise re-hit the upstream when
|
||||
// CacheExternal is false.
|
||||
func TestImage_RevalidationSkipsIO(t *testing.T) {
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||
t.Fatal("Load must not be called on the revalidation short-circuit path")
|
||||
return nil, nil
|
||||
}}
|
||||
img := Image{
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
// no Transport — any downloadImage attempt would also fail
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
encodedSrc := base64.URLEncoding.EncodeToString([]byte("https://example.com/whatever.png"))
|
||||
currentEtag := `"v2:` + encodedSrc + `"`
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL+"/?src="+encodedSrc, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("If-None-Match", currentEtag)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotModified, resp.StatusCode,
|
||||
"matching current-version etag must short-circuit to 304 without I/O")
|
||||
assert.Equal(t, 0, len(imageStore.LoadCalls()),
|
||||
"revalidation must not trigger store Load")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
assert.Empty(t, body, "304 must have no body")
|
||||
// defense headers must still be set on the short-circuit path
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "default-src 'none'")
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "sandbox")
|
||||
assert.Equal(t, currentEtag, resp.Header.Get("Etag"))
|
||||
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age=2592000")
|
||||
}
|
||||
|
||||
// TestImage_PerRequestRevalidation proves the defense holds when upstream flips its
|
||||
// response body between requests (give a real PNG once, HTML next time, etc.). Each
|
||||
// proxy response is independently validated against the body actually returned, so
|
||||
// trust never accumulates and an earlier "good" response cannot grant the next one a
|
||||
// free pass.
|
||||
func TestImage_PerRequestRevalidation(t *testing.T) {
|
||||
htmlBody := []byte("<html><script>alert(1)</script></html>")
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png") // always lie consistently
|
||||
switch r.URL.Path {
|
||||
case "/png":
|
||||
_, _ = w.Write(gopherPNGBytes())
|
||||
case "/html":
|
||||
_, _ = w.Write(htmlBody)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
RemarkURL: "https://demo.remark42.com",
|
||||
RoutePath: "/api/v1/proxy",
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
defer ts.Close()
|
||||
|
||||
// alternate calls: PNG, HTML, PNG, HTML — each must be judged on its own bytes.
|
||||
type step struct {
|
||||
path string
|
||||
wantStatus int
|
||||
wantCT string // prefix match
|
||||
}
|
||||
steps := []step{
|
||||
{path: "/png", wantStatus: http.StatusOK, wantCT: "image/png"},
|
||||
{path: "/html", wantStatus: http.StatusUnsupportedMediaType, wantCT: "application/json"},
|
||||
{path: "/png", wantStatus: http.StatusOK, wantCT: "image/png"},
|
||||
{path: "/html", wantStatus: http.StatusUnsupportedMediaType, wantCT: "application/json"},
|
||||
}
|
||||
for i, s := range steps {
|
||||
t.Run(fmt.Sprintf("step_%d_%s", i, s.path), func(t *testing.T) {
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte(upstream.URL + s.path))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedURL)
|
||||
require.NoError(t, err)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
assert.Equal(t, s.wantStatus, resp.StatusCode)
|
||||
assert.True(t, strings.HasPrefix(resp.Header.Get("Content-Type"), s.wantCT),
|
||||
"expected Content-Type prefix %q, got %q", s.wantCT, resp.Header.Get("Content-Type"))
|
||||
assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"),
|
||||
"must never serve text/html under any flip")
|
||||
assert.NotContains(t, string(body), "<script>",
|
||||
"attacker payload must never appear in response body")
|
||||
// every response must still carry the defense headers
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Contains(t, resp.Header.Get("Content-Disposition"), "inline")
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"),
|
||||
"default-src 'none'; sandbox; frame-ancestors 'none'")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func imgHTTPTestsServer(t *testing.T) *httptest.Server {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/image/img1.png" {
|
||||
|
||||
@@ -56,7 +56,7 @@ func SetUserInfo(r *http.Request, user store.User) *http.Request {
|
||||
Picture: user.Picture,
|
||||
IP: user.IP,
|
||||
Audience: user.SiteID,
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"blocked": user.Blocked,
|
||||
"verified": user.Verified,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package safehttp provides HTTP transports hardened against SSRF: outbound
|
||||
// connections are dialed using a pre-resolved IP, with a check that all
|
||||
// resolved IPs sit outside private/reserved ranges. This blocks both naive
|
||||
// SSRF (private IP literals in user-supplied URLs) and DNS rebinding.
|
||||
package safehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Transport returns an *http.Transport whose DialContext refuses any address
|
||||
// that resolves to a private/reserved IP, choosing the IP itself for the dial
|
||||
// to defeat DNS rebinding (an attacker cannot have the resolver hand back a
|
||||
// public IP at the check and a private one at the connect).
|
||||
//
|
||||
// The returned transport is a clone of http.DefaultTransport with only
|
||||
// DialContext overridden, preserving Proxy, HTTP/2, idle/keep-alive and
|
||||
// TLS handshake timeouts that bare &http.Transport{} would lose.
|
||||
func Transport() *http.Transport {
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid address %s: %w", addr, err)
|
||||
}
|
||||
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't resolve host %s: %w", host, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("no IP addresses resolved for host %s", host)
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if IsPrivateIP(ip.IP) {
|
||||
return nil, fmt.Errorf("access to private address is not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, fmt.Errorf("can't connect to %s: %w", host, lastErr)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// privateCIDRs holds pre-parsed private/reserved CIDR blocks.
|
||||
var privateCIDRs = func() []*net.IPNet {
|
||||
cidrs := []string{
|
||||
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
|
||||
"100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
||||
"::1/128", "fc00::/7", "fe80::/10",
|
||||
}
|
||||
blocks := make([]*net.IPNet, 0, len(cidrs))
|
||||
for _, cidr := range cidrs {
|
||||
_, block, _ := net.ParseCIDR(cidr)
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
return blocks
|
||||
}()
|
||||
|
||||
// IsPrivateIP reports whether ip falls in any private, loopback, link-local,
|
||||
// CGNAT, or reserved range — including IPv4 and IPv6 unspecified addresses.
|
||||
func IsPrivateIP(ip net.IP) bool {
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
for _, block := range privateCIDRs {
|
||||
if block.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package safehttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tbl := []struct {
|
||||
ip string
|
||||
private bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"10.0.0.1", true},
|
||||
{"10.255.255.255", true},
|
||||
{"172.16.0.1", true},
|
||||
{"172.31.255.255", true},
|
||||
{"192.168.0.1", true},
|
||||
{"192.168.255.255", true},
|
||||
{"169.254.1.1", true},
|
||||
{"100.64.0.1", true},
|
||||
{"100.127.255.255", true},
|
||||
{"::1", true},
|
||||
{"fc00::1", true},
|
||||
{"fe80::1", true},
|
||||
{"0.0.0.0", true},
|
||||
{"::", true},
|
||||
{"8.8.8.8", false},
|
||||
{"203.0.113.1", false},
|
||||
{"1.1.1.1", false},
|
||||
{"2001:db8::1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tbl {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
require.NotNil(t, ip)
|
||||
assert.Equal(t, tt.private, IsPrivateIP(ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransport_BlocksPrivate(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client := &http.Client{Transport: Transport(), Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(srv.URL) // httptest.NewServer binds 127.0.0.1
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
require.Error(t, err, "private address must be refused")
|
||||
assert.Contains(t, err.Error(), "access to private address is not allowed")
|
||||
}
|
||||
|
||||
func TestTransport_AllowsPublic(t *testing.T) {
|
||||
tr := Transport()
|
||||
// the policy check must reject the loopback literal
|
||||
_, err := tr.DialContext(context.Background(), "tcp", "127.0.0.1:1")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "access to private address is not allowed")
|
||||
|
||||
// public IP literal passes the policy check; bound the dial with a tight context
|
||||
// so the test does not depend on real-world routing of TEST-NET-3 (203.0.113.0/24).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, err = tr.DialContext(ctx, "tcp", "203.0.113.1:1")
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), "access to private address is not allowed")
|
||||
}
|
||||
|
||||
func TestTransport_PreservesDefaultTransportSettings(t *testing.T) {
|
||||
def := http.DefaultTransport.(*http.Transport)
|
||||
tr := Transport()
|
||||
assert.NotNil(t, tr.Proxy, "Proxy must be inherited from http.DefaultTransport")
|
||||
assert.Equal(t, def.ForceAttemptHTTP2, tr.ForceAttemptHTTP2, "ForceAttemptHTTP2")
|
||||
assert.Equal(t, def.MaxIdleConns, tr.MaxIdleConns, "MaxIdleConns")
|
||||
assert.Equal(t, def.IdleConnTimeout, tr.IdleConnTimeout, "IdleConnTimeout")
|
||||
assert.Equal(t, def.TLSHandshakeTimeout, tr.TLSHandshakeTimeout, "TLSHandshakeTimeout")
|
||||
assert.Equal(t, def.ExpectContinueTimeout, tr.ExpectContinueTimeout, "ExpectContinueTimeout")
|
||||
}
|
||||
@@ -50,8 +50,8 @@ type PostInfo struct {
|
||||
CountLeft int `json:"count_left"` // used only with returning search results limited by number, otherwise zero
|
||||
LastComment string `json:"last_comment,omitempty"` // used only with returning search results limited by number
|
||||
ReadOnly bool `json:"read_only,omitempty" bson:"read_only,omitempty"` // can be attached to site-wide comments but won't be set then
|
||||
FirstTS time.Time `json:"first_time,omitempty" bson:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time,omitempty" bson:"last_time,omitempty"`
|
||||
FirstTS time.Time `json:"first_time" bson:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time" bson:"last_time,omitempty"`
|
||||
}
|
||||
|
||||
// BlockedUser holds id and ts for blocked user
|
||||
@@ -157,7 +157,7 @@ func (c *Comment) Snippet(limit int) string {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Don't add a space if comment is just a one single word which has been truncated.
|
||||
// don't add a space if comment is just a one single word which has been truncated.
|
||||
if len(snippet) == limit {
|
||||
return string(snippet) + "..."
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestComment_PrepareUntrusted(t *testing.T) {
|
||||
Score: 10,
|
||||
Pin: true,
|
||||
Deleted: true,
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.UTC),
|
||||
Votes: map[string]bool{"uu": true},
|
||||
Controversy: 123,
|
||||
Imported: true,
|
||||
@@ -150,7 +150,7 @@ func TestComment_SetDeleted(t *testing.T) {
|
||||
Locator: Locator{SiteID: "site", URL: "url"},
|
||||
Score: 10,
|
||||
Deleted: false,
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.UTC),
|
||||
Votes: map[string]bool{"uu": true},
|
||||
Pin: true,
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func TestComment_SetDeletedHard(t *testing.T) {
|
||||
Locator: Locator{SiteID: "site", URL: "url"},
|
||||
Score: 10,
|
||||
Deleted: false,
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.UTC),
|
||||
Votes: map[string]bool{"uu": true},
|
||||
Pin: true,
|
||||
}
|
||||
|
||||
@@ -346,13 +346,13 @@ func (b *BoltDB) Info(req InfoRequest) ([]store.PostInfo, error) {
|
||||
|
||||
// ListFlags get list of flagged keys, like blocked & verified user
|
||||
// works for full locator (post flags) or with userID
|
||||
func (b *BoltDB) ListFlags(req FlagRequest) (res []interface{}, err error) {
|
||||
func (b *BoltDB) ListFlags(req FlagRequest) (res []any, err error) {
|
||||
bdb, e := b.db(req.Locator.SiteID)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
|
||||
res = []interface{}{}
|
||||
res = []any{}
|
||||
switch req.Flag {
|
||||
case Verified:
|
||||
err = bdb.View(func(tx *bolt.Tx) error {
|
||||
@@ -957,7 +957,7 @@ func (b *BoltDB) getUserBucket(tx *bolt.Tx, userID string) (*bolt.Bucket, error)
|
||||
}
|
||||
|
||||
// save marshaled value to key for bucket. Should run in update tx
|
||||
func (b *BoltDB) save(bkt *bolt.Bucket, key string, value interface{}) (err error) {
|
||||
func (b *BoltDB) save(bkt *bolt.Bucket, key string, value any) (err error) {
|
||||
if value == nil {
|
||||
return fmt.Errorf("can't save nil value for %s", key)
|
||||
}
|
||||
@@ -972,7 +972,7 @@ func (b *BoltDB) save(bkt *bolt.Bucket, key string, value interface{}) (err erro
|
||||
}
|
||||
|
||||
// load and unmarshal json value by key from bucket. Should run in view tx
|
||||
func (b *BoltDB) load(bkt *bolt.Bucket, key string, res interface{}) error {
|
||||
func (b *BoltDB) load(bkt *bolt.Bucket, key string, res any) error {
|
||||
value := bkt.Get([]byte(key))
|
||||
if value == nil {
|
||||
return fmt.Errorf("no value for %s", key)
|
||||
@@ -1027,7 +1027,7 @@ func (b *BoltDB) db(siteID string) (*bolt.DB, error) {
|
||||
|
||||
// makeRef creates reference combining url and comment id
|
||||
func (b *BoltDB) makeRef(comment store.Comment) []byte {
|
||||
return []byte(fmt.Sprintf("%s!!%s", comment.Locator.URL, comment.ID))
|
||||
return fmt.Appendf(nil, "%s!!%s", comment.Locator.URL, comment.ID)
|
||||
}
|
||||
|
||||
// parseRef gets parts of reference
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -48,7 +49,7 @@ func TestBoltDB_CreateFailedReadOnly(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "id-ro",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/ro", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -147,20 +148,20 @@ func TestBoltDB_FindLastSince(t *testing.T) {
|
||||
var b, teardown = prep(t)
|
||||
defer teardown()
|
||||
|
||||
ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.Local)
|
||||
ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.UTC)
|
||||
req := FindRequest{Locator: store.Locator{SiteID: "radio-t"}, Sort: "-time", Since: ts}
|
||||
res, err := b.Find(req)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 2, len(res))
|
||||
assert.Equal(t, "some text2", res[0].Text)
|
||||
|
||||
req.Since = time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local)
|
||||
req.Since = time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC)
|
||||
res, err = b.Find(req)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 1, len(res))
|
||||
assert.Equal(t, "some text2", res[0].Text)
|
||||
|
||||
req.Since = time.Date(2017, 12, 20, 16, 18, 22, 0, time.Local)
|
||||
req.Since = time.Date(2017, 12, 20, 16, 18, 22, 0, time.UTC)
|
||||
res, err = b.Find(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(res))
|
||||
@@ -170,20 +171,20 @@ func TestBoltDB_FindInPostSince(t *testing.T) {
|
||||
var b, teardown = prep(t)
|
||||
defer teardown()
|
||||
|
||||
ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.Local)
|
||||
ts := time.Date(2017, 12, 20, 15, 18, 21, 0, time.UTC)
|
||||
req := FindRequest{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, Sort: "-time", Since: ts}
|
||||
res, err := b.Find(req)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 2, len(res))
|
||||
assert.Equal(t, "some text2", res[0].Text)
|
||||
|
||||
req.Since = time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local)
|
||||
req.Since = time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC)
|
||||
res, err = b.Find(req)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 1, len(res))
|
||||
assert.Equal(t, "some text2", res[0].Text)
|
||||
|
||||
req.Since = time.Date(2017, 12, 20, 16, 18, 22, 0, time.Local)
|
||||
req.Since = time.Date(2017, 12, 20, 16, 18, 22, 0, time.UTC)
|
||||
res, err = b.Find(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(res))
|
||||
@@ -236,10 +237,10 @@ func TestBoltDB_FindForUserPagination(t *testing.T) {
|
||||
}
|
||||
|
||||
// write 200 comments
|
||||
for i := 0; i < 200; i++ {
|
||||
for i := range 200 {
|
||||
c.ID = fmt.Sprintf("id-%d", i)
|
||||
c.Text = fmt.Sprintf("text #%d", i)
|
||||
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
|
||||
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.UTC)
|
||||
_, err = b.Create(c)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -324,13 +325,13 @@ func TestBoltDB_InfoPost(t *testing.T) {
|
||||
b, teardown := prep(t) // two comments for https://radio-t.com
|
||||
defer teardown()
|
||||
|
||||
ts := func(minute int) time.Time { return time.Date(2017, 12, 20, 15, 18, minute, 0, time.Local) }
|
||||
ts := func(minute int) time.Time { return time.Date(2017, 12, 20, 15, 18, minute, 0, time.UTC) }
|
||||
|
||||
// add one more for https://radio-t.com/2
|
||||
comment := store.Comment{
|
||||
ID: "12345",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 24, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 24, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -379,14 +380,14 @@ func TestBoltDB_InfoList(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "12345",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Create(comment)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ts := func(sec int) time.Time { return time.Date(2017, 12, 20, 15, 18, sec, 0, time.Local) }
|
||||
ts := func(sec int) time.Time { return time.Date(2017, 12, 20, 15, 18, sec, 0, time.UTC) }
|
||||
|
||||
req := InfoRequest{Locator: store.Locator{SiteID: "radio-t"}}
|
||||
res, err := b.Info(req)
|
||||
@@ -540,7 +541,7 @@ func TestBolt_FlagListVerified(t *testing.T) {
|
||||
b, teardown := prep(t)
|
||||
defer teardown()
|
||||
|
||||
toIDs := func(inp []interface{}) (res []string) {
|
||||
toIDs := func(inp []any) (res []string) {
|
||||
res = make([]string, len(inp))
|
||||
for i, v := range inp {
|
||||
vv, ok := v.(string)
|
||||
@@ -571,47 +572,49 @@ func TestBolt_FlagListVerified(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBolt_FlagListBlocked(t *testing.T) {
|
||||
b, teardown := prep(t)
|
||||
defer teardown()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
b, teardown := prep(t)
|
||||
defer teardown()
|
||||
|
||||
setBlocked := func(site, user string, status FlagStatus, ttl time.Duration) error {
|
||||
req := FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status, TTL: ttl}
|
||||
_, err := b.Flag(req)
|
||||
return err
|
||||
}
|
||||
|
||||
toBlocked := func(inp []interface{}) (res []store.BlockedUser) {
|
||||
res = make([]store.BlockedUser, len(inp))
|
||||
for i, v := range inp {
|
||||
vv, ok := v.(store.BlockedUser)
|
||||
require.True(t, ok)
|
||||
res[i] = vv
|
||||
setBlocked := func(site, user string, status FlagStatus, ttl time.Duration) error {
|
||||
req := FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status, TTL: ttl}
|
||||
_, err := b.Flag(req)
|
||||
return err
|
||||
}
|
||||
return res
|
||||
}
|
||||
assert.NoError(t, setBlocked("radio-t", "user1", FlagTrue, 0))
|
||||
assert.NoError(t, setBlocked("radio-t", "user2", FlagTrue, 150*time.Millisecond))
|
||||
assert.NoError(t, setBlocked("radio-t", "user3", FlagFalse, 0))
|
||||
|
||||
vv, err := b.ListFlags(FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
toBlocked := func(inp []any) (res []store.BlockedUser) {
|
||||
res = make([]store.BlockedUser, len(inp))
|
||||
for i, v := range inp {
|
||||
vv, ok := v.(store.BlockedUser)
|
||||
require.True(t, ok)
|
||||
res[i] = vv
|
||||
}
|
||||
return res
|
||||
}
|
||||
assert.NoError(t, setBlocked("radio-t", "user1", FlagTrue, 0))
|
||||
assert.NoError(t, setBlocked("radio-t", "user2", FlagTrue, 150*time.Millisecond))
|
||||
assert.NoError(t, setBlocked("radio-t", "user3", FlagFalse, 0))
|
||||
|
||||
blockedList := toBlocked(vv)
|
||||
require.Equal(t, 2, len(blockedList))
|
||||
assert.Equal(t, "user1", blockedList[0].ID)
|
||||
assert.Equal(t, "user2", blockedList[1].ID)
|
||||
t.Logf("%+v", blockedList)
|
||||
vv, err := b.ListFlags(FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// check block expiration
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
vv, err = b.ListFlags(FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
blockedList = toBlocked(vv)
|
||||
require.Equal(t, 1, len(blockedList))
|
||||
assert.Equal(t, "user1", blockedList[0].ID)
|
||||
blockedList := toBlocked(vv)
|
||||
require.Equal(t, 2, len(blockedList))
|
||||
assert.Equal(t, "user1", blockedList[0].ID)
|
||||
assert.Equal(t, "user2", blockedList[1].ID)
|
||||
t.Logf("%+v", blockedList)
|
||||
|
||||
_, err = b.ListFlags(FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: "bad"}})
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
// check block expiration
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
vv, err = b.ListFlags(FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||
assert.NoError(t, err)
|
||||
blockedList = toBlocked(vv)
|
||||
require.Equal(t, 1, len(blockedList))
|
||||
assert.Equal(t, "user1", blockedList[0].ID)
|
||||
|
||||
_, err = b.ListFlags(FlagRequest{Flag: Blocked, Locator: store.Locator{SiteID: "bad"}})
|
||||
assert.EqualError(t, err, `site "bad" not found`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoltDB_UserDetail(t *testing.T) {
|
||||
@@ -890,7 +893,7 @@ func TestBoltDB_ref(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "12345",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -929,7 +932,7 @@ func prep(t *testing.T) (b *BoltDB, teardown func()) {
|
||||
comment := store.Comment{
|
||||
ID: "id-1",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -939,7 +942,7 @@ func prep(t *testing.T) (b *BoltDB, teardown func()) {
|
||||
comment = store.Comment{
|
||||
ID: "id-2",
|
||||
Text: "some text2",
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Interface interface {
|
||||
Count(req FindRequest) (int, error) // get count for post or user
|
||||
Delete(req DeleteRequest) error // Delete post(s), user, comment, user details, or everything
|
||||
Flag(req FlagRequest) (bool, error) // set and get flags
|
||||
ListFlags(req FlagRequest) ([]interface{}, error) // get list of flagged keys, like blocked & verified user
|
||||
ListFlags(req FlagRequest) ([]any, error) // get list of flagged keys, like blocked & verified user
|
||||
|
||||
// UserDetail sets or gets single detail value, or gets all details for requested site
|
||||
// Returns list even for single entry request is a compromise in order to have both single detail getting and setting
|
||||
@@ -45,7 +45,7 @@ type FindRequest struct {
|
||||
Locator store.Locator `json:"locator"` // lack of URL means site operation
|
||||
UserID string `json:"user_id,omitempty"` // presence of UserID treated as user-related find
|
||||
Sort string `json:"sort,omitempty"` // sort order with +/-field syntax
|
||||
Since time.Time `json:"since,omitempty"` // time limit for found results
|
||||
Since time.Time `json:"since"` // time limit for found results
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Skip int `json:"skip,omitempty"`
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
store "github.com/umputun/remark42/backend/app/store"
|
||||
"sync"
|
||||
|
||||
store "github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// Ensure, that InterfaceMock does implement Interface.
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
func TestEngine_sortComments(t *testing.T) {
|
||||
cc := []store.Comment{
|
||||
{ID: "1", Score: 5, Controversy: 1, Timestamp: time.Date(2018, 2, 5, 10, 1, 0, 0, time.Local)},
|
||||
{ID: "2", Score: 4, Controversy: 2, Timestamp: time.Date(2018, 2, 5, 10, 2, 0, 0, time.Local)},
|
||||
{ID: "3", Score: 6, Controversy: 3, Timestamp: time.Date(2018, 2, 5, 10, 3, 0, 0, time.Local)},
|
||||
{ID: "4", Score: 6, Controversy: 1, Timestamp: time.Date(2018, 2, 5, 10, 4, 0, 0, time.Local)},
|
||||
{ID: "1", Score: 5, Controversy: 1, Timestamp: time.Date(2018, 2, 5, 10, 1, 0, 0, time.UTC)},
|
||||
{ID: "2", Score: 4, Controversy: 2, Timestamp: time.Date(2018, 2, 5, 10, 2, 0, 0, time.UTC)},
|
||||
{ID: "3", Score: 6, Controversy: 3, Timestamp: time.Date(2018, 2, 5, 10, 3, 0, 0, time.UTC)},
|
||||
{ID: "4", Score: 6, Controversy: 1, Timestamp: time.Date(2018, 2, 5, 10, 4, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
SortComments(cc, "+time")
|
||||
|
||||
@@ -71,24 +71,24 @@ func (r *RPC) Flag(req FlagRequest) (status bool, err error) {
|
||||
return status, err
|
||||
}
|
||||
|
||||
func unmarshalString(data []byte) ([]interface{}, error) {
|
||||
func unmarshalString(data []byte) ([]any, error) {
|
||||
var strings []string
|
||||
if err := json.Unmarshal(data, &strings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]interface{}, 0, len(strings))
|
||||
list := make([]any, 0, len(strings))
|
||||
for _, w := range strings {
|
||||
list = append(list, w)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func unmarshalBlockedUser(data []byte) ([]interface{}, error) {
|
||||
func unmarshalBlockedUser(data []byte) ([]any, error) {
|
||||
var blockedUsers []store.BlockedUser
|
||||
if err := json.Unmarshal(data, &blockedUsers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]interface{}, 0, len(blockedUsers))
|
||||
list := make([]any, 0, len(blockedUsers))
|
||||
for _, w := range blockedUsers {
|
||||
list = append(list, w)
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func unmarshalBlockedUser(data []byte) ([]interface{}, error) {
|
||||
}
|
||||
|
||||
// ListFlags get list of flagged keys, like blocked & verified user
|
||||
func (r *RPC) ListFlags(req FlagRequest) ([]interface{}, error) {
|
||||
func (r *RPC) ListFlags(req FlagRequest) ([]any, error) {
|
||||
resp, err := r.Call("store.list_flags", req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestFormatter_FormatComment(t *testing.T) {
|
||||
Score: 10,
|
||||
Pin: true,
|
||||
Deleted: true,
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 1, 1, 9, 30, 0, 0, time.UTC),
|
||||
Votes: map[string]bool{"uu": true},
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -89,50 +90,53 @@ func TestBoltStore_LoadAfterDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBoltStore_Cleanup(t *testing.T) {
|
||||
svc, teardown := prepareBoltImageStorageTest(t)
|
||||
defer teardown()
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
svc, teardown := prepareBoltImageStorageTest(t)
|
||||
defer teardown()
|
||||
|
||||
save := func(file string) (id string) {
|
||||
err := svc.Save(file, gopherPNGBytes())
|
||||
save := func(file string) (id string) {
|
||||
err := svc.Save(file, gopherPNGBytes())
|
||||
require.NoError(t, err)
|
||||
|
||||
checkBoltImgData(t, svc.db, imagesStagedBktName, file, func(data []byte) error {
|
||||
require.NotNil(t, data)
|
||||
assert.Equal(t, 1462, len(data))
|
||||
return nil
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
// save 3 images to staging
|
||||
img1 := save("blah_ff1.png")
|
||||
img1ts := time.Now()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
img2 := save("blah_ff2.png")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
img3 := save("blah_ff3.png")
|
||||
|
||||
// Cleanup check is `age > ttl` (strict), so pick a ttl strictly less than img1's age
|
||||
err := svc.Cleanup(context.Background(), time.Since(img1ts)-time.Millisecond)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assertBoltImgNil(t, svc.db, imagesStagedBktName, img1)
|
||||
assertBoltImgNil(t, svc.db, imagesBktName, img1)
|
||||
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img2)
|
||||
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img3)
|
||||
|
||||
err = svc.Commit(img3)
|
||||
require.NoError(t, err)
|
||||
|
||||
checkBoltImgData(t, svc.db, imagesStagedBktName, file, func(data []byte) error {
|
||||
require.NotNil(t, data)
|
||||
assert.Equal(t, 1462, len(data))
|
||||
return nil
|
||||
})
|
||||
return file
|
||||
}
|
||||
// reset the time to cleanup
|
||||
err = svc.ResetCleanupTimer(img2)
|
||||
require.NoError(t, err)
|
||||
err = svc.Cleanup(context.Background(), time.Millisecond*100)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// save 3 images to staging
|
||||
img1 := save("blah_ff1.png")
|
||||
img1ts := time.Now()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
img2 := save("blah_ff2.png")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
img3 := save("blah_ff3.png")
|
||||
|
||||
err := svc.Cleanup(context.Background(), time.Since(img1ts)) // clean first images
|
||||
assert.NoError(t, err)
|
||||
|
||||
assertBoltImgNil(t, svc.db, imagesStagedBktName, img1)
|
||||
assertBoltImgNil(t, svc.db, imagesBktName, img1)
|
||||
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img2)
|
||||
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img3)
|
||||
|
||||
err = svc.Commit(img3)
|
||||
require.NoError(t, err)
|
||||
|
||||
// reset the time to cleanup
|
||||
err = svc.ResetCleanupTimer(img2)
|
||||
require.NoError(t, err)
|
||||
err = svc.Cleanup(context.Background(), time.Millisecond*100)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img2)
|
||||
assertBoltImgNil(t, svc.db, imagesBktName, img2)
|
||||
assertBoltImgNotNil(t, svc.db, imagesBktName, img3)
|
||||
assert.NoError(t, err)
|
||||
assertBoltImgNotNil(t, svc.db, imagesStagedBktName, img2)
|
||||
assertBoltImgNil(t, svc.db, imagesBktName, img2)
|
||||
assertBoltImgNotNil(t, svc.db, imagesBktName, img3)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBolt_Info(t *testing.T) {
|
||||
|
||||
@@ -37,11 +37,11 @@ type FileSystem struct {
|
||||
func (f *FileSystem) Save(id string, img []byte) error {
|
||||
dst := f.location(f.Staging, id)
|
||||
|
||||
if err := os.MkdirAll(path.Dir(dst), 0o700); err != nil {
|
||||
if err := os.MkdirAll(path.Dir(dst), 0o700); err != nil { //nolint:gosec // id is server-generated hash via image.Service (Save / SaveWithID); dst computed via f.location
|
||||
return fmt.Errorf("can't make image directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(dst, img, 0o600); err != nil {
|
||||
if err := os.WriteFile(dst, img, 0o600); err != nil { //nolint:gosec // same as MkdirAll above
|
||||
return fmt.Errorf("can't write image file with id %s: %w", id, err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
_ = os.Remove(path.Dir(fpath)) // try to remove directory
|
||||
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
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestFsStore_location(t *testing.T) {
|
||||
}
|
||||
|
||||
svc := FileSystem{Location: "/tmp", Partitions: 10}
|
||||
for i := 0; i < 1000; i++ {
|
||||
for range 1000 {
|
||||
v := randomID(rand.Intn(64))
|
||||
location := svc.location("/tmp", v)
|
||||
elems := strings.Split(location, "/")
|
||||
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
// support gif and jpeg images decoding
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/gif" // register gif decoder
|
||||
_ "image/jpeg" // register jpeg decoder
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -31,6 +30,7 @@ import (
|
||||
"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
|
||||
)
|
||||
|
||||
// Service wraps Store with common functions needed for any store implementation
|
||||
@@ -110,9 +110,7 @@ func (s *Service) Submit(idsFn func() []string) {
|
||||
s.once.Do(func() {
|
||||
log.Printf("[DEBUG] image submitter activated")
|
||||
s.submitCh = make(chan submitReq, submitQueueSize)
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
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 {
|
||||
@@ -126,7 +124,7 @@ func (s *Service) Submit(idsFn func() []string) {
|
||||
atomic.AddInt32(&s.submitCount, -1)
|
||||
}
|
||||
log.Printf("[INFO] image submitter terminated")
|
||||
}()
|
||||
})
|
||||
})
|
||||
|
||||
atomic.AddInt32(&s.submitCount, 1)
|
||||
@@ -154,6 +152,12 @@ func (s *Service) ExtractNonProxiedPictures(commentHTML string) (ids []string) {
|
||||
|
||||
// Cleanup runs periodic cleanup with 1.5*ServiceParams.EditDuration. Blocking loop, should be called inside of goroutine by consumer
|
||||
func (s *Service) Cleanup(ctx context.Context) {
|
||||
if s.EditDuration <= 0 {
|
||||
log.Printf("[INFO] pictures cleanup disabled, edit duration is %v", s.EditDuration)
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
|
||||
cleanupTTL := s.EditDuration * 15 / 10 // cleanup images older than 1.5 * EditDuration
|
||||
log.Printf("[INFO] start pictures cleanup, staging ttl=%v", cleanupTTL)
|
||||
|
||||
@@ -233,16 +237,6 @@ func (s *Service) SaveWithID(id string, r io.Reader) error {
|
||||
return s.store.Save(id, img)
|
||||
}
|
||||
|
||||
// ImgContentType returns content type for provided image
|
||||
func (s *Service) ImgContentType(img []byte) string {
|
||||
contentType := http.DetectContentType(img)
|
||||
if contentType == "application/octet-stream" {
|
||||
// replace generic fallback with one which make sense in our scenario
|
||||
return "image/*"
|
||||
}
|
||||
return contentType
|
||||
}
|
||||
|
||||
// returns list of image IDs from the comment html, including proxied images if includeProxied is true
|
||||
func (s *Service) extractImageIDs(commentHTML string, includeProxied bool) (ids []string) {
|
||||
doc, err := goquery.NewDocumentFromReader(strings.NewReader(commentHTML))
|
||||
@@ -280,6 +274,13 @@ func (s *Service) extractImageIDs(commentHTML string, includeProxied bool) (ids
|
||||
return ids
|
||||
}
|
||||
|
||||
// maxImagePixels caps the declared pixel count of an image before any raster decode
|
||||
// is allowed. Without this, a tiny compressed "decompression bomb" image declaring
|
||||
// e.g. 65535x65535 px would force image.Decode to allocate gigabytes of pixel memory
|
||||
// and OOM the service on a single comment upload. 16 MP covers any realistic image
|
||||
// (~4096x4096) while keeping peak allocation bounded.
|
||||
const maxImagePixels = 16 * 1024 * 1024
|
||||
|
||||
// prepareImage calls readAndValidateImage and resize on provided image.
|
||||
func (s *Service) prepareImage(r io.Reader) ([]byte, error) {
|
||||
data, err := readAndValidateImage(r, s.MaxSize)
|
||||
@@ -287,32 +288,59 @@ func (s *Service) prepareImage(r io.Reader) ([]byte, error) {
|
||||
return nil, fmt.Errorf("can't load image: %w", err)
|
||||
}
|
||||
|
||||
data = resize(data, s.MaxWidth, s.MaxHeight)
|
||||
return data, nil
|
||||
resized := resize(data, s.MaxWidth, s.MaxHeight)
|
||||
if resized == nil {
|
||||
return nil, fmt.Errorf("image rejected: malformed or exceeds %d-pixel safe limit", maxImagePixels)
|
||||
}
|
||||
return resized, nil
|
||||
}
|
||||
|
||||
// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of
|
||||
// the biggest side (width or height) preserving aspect ratio.
|
||||
// Returns original data if resizing is not needed or failed.
|
||||
// If resized the result will be for png format
|
||||
// resize validates an image and, if needed, re-encodes it to fit within the given
|
||||
// pixel limits preserving aspect ratio. Returns nil for malformed input or for
|
||||
// declared dimensions exceeding maxImagePixels so attacker payloads (decompression
|
||||
// bombs) never reach the store. With limit <= 0 or when the image already fits, the
|
||||
// original bytes are returned verbatim so animated GIFs and other multi-frame formats
|
||||
// round-trip without being flattened to one frame.
|
||||
//
|
||||
// Validation uses image.DecodeConfig (cheap — declares dimensions, allocates nothing)
|
||||
// before any full image.Decode, so a 100 KB compressed image declaring 65535x65535 px
|
||||
// is rejected without ever materializing the raster.
|
||||
func resize(data []byte, limitW, limitH int) []byte {
|
||||
if data == nil || limitW <= 0 || limitH <= 0 {
|
||||
return data
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
src, _, err := image.Decode(bytes.NewBuffer(data))
|
||||
// validate format and dimensions without allocating pixel memory.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't decode image, %s", err)
|
||||
return data
|
||||
log.Printf("[WARN] can't decode image config, %s", err)
|
||||
return nil
|
||||
}
|
||||
// multiply in int64 — on 32-bit builds (GOARCH=386, 32-bit arm) the int
|
||||
// product of two 16-bit-or-larger dimensions can overflow and wrap below
|
||||
// maxImagePixels, bypassing the cap.
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > int64(maxImagePixels) {
|
||||
log.Printf("[WARN] image dimensions %dx%d exceed safe limit", cfg.Width, cfg.Height)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dimensions are bounded — full decode is now safe to allocate. Decode also
|
||||
// validates the raster body: a header that DecodeConfig accepts but with a
|
||||
// corrupt or truncated payload would slip through if we returned early on the
|
||||
// no-resize path without ever touching the pixels. Decode unconditionally,
|
||||
// then either return the original bytes (no resize needed, multi-frame intact)
|
||||
// or the re-encoded result.
|
||||
src, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't decode image after dim-check, %s", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
bounds := src.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w <= limitW && h <= limitH || w <= 0 || h <= 0 {
|
||||
log.Printf("[DEBUG] resizing image is smaller that the limit or has 0 size")
|
||||
if limitW <= 0 || limitH <= 0 || (cfg.Width <= limitW && cfg.Height <= limitH) {
|
||||
return data
|
||||
}
|
||||
|
||||
w, h := src.Bounds().Dx(), src.Bounds().Dy()
|
||||
newW, newH := getProportionalSizes(w, h, limitW, limitH)
|
||||
m := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||
draw.CatmullRom.Scale(m, m.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
@@ -320,7 +348,7 @@ func resize(data []byte, limitW, limitH int) []byte {
|
||||
var out bytes.Buffer
|
||||
if err = png.Encode(&out, m); err != nil {
|
||||
log.Printf("[WARN] can't encode resized image to png, %s", err)
|
||||
return data
|
||||
return data // fall back to the validated original
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -361,8 +389,14 @@ func readAndValidateImage(r io.Reader, maxSize int) ([]byte, error) {
|
||||
return nil, fmt.Errorf("file is too large (limit=%d)", maxSize)
|
||||
}
|
||||
|
||||
// read header first, needs it to check if data is valid png/gif/jpeg
|
||||
if !isValidImage(data[:512]) {
|
||||
// read header first to check the format. http.DetectContentType inspects up
|
||||
// to the first 512 bytes, but a smaller body is fine — pass the whole slice
|
||||
// rather than panicking on a fixed-size sub-slice.
|
||||
header := data
|
||||
if len(header) > 512 {
|
||||
header = header[:512]
|
||||
}
|
||||
if !isValidImage(header) {
|
||||
return nil, fmt.Errorf("file format not allowed")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -56,10 +57,10 @@ func TestService_ResizeJpeg(t *testing.T) {
|
||||
|
||||
img, err := readAndValidateImage(fh, 32000)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 16756, len(img))
|
||||
assert.InDelta(t, 16756, len(img), 100)
|
||||
|
||||
img = resize(img, 400, 300)
|
||||
assert.Equal(t, 10918, len(img))
|
||||
assert.InDelta(t, 10913, len(img), 100)
|
||||
}
|
||||
|
||||
func TestService_SaveTooLarge(t *testing.T) {
|
||||
@@ -125,37 +126,41 @@ func TestService_ExtractPictures(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_Cleanup(t *testing.T) {
|
||||
store := StoreMock{
|
||||
CleanupFunc: func(context.Context, time.Duration) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
store := StoreMock{
|
||||
CleanupFunc: func(context.Context, time.Duration) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
||||
// cancel context after 2.1 cleanup TTLs
|
||||
ctx, cancel := context.WithTimeout(context.Background(), svc.EditDuration/100*15*21)
|
||||
defer cancel()
|
||||
svc.Cleanup(ctx)
|
||||
assert.Equal(t, 2, len(store.CleanupCalls()))
|
||||
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
||||
// cancel context after 2.1 cleanup TTLs
|
||||
ctx, cancel := context.WithTimeout(context.Background(), svc.EditDuration/100*15*21)
|
||||
defer cancel()
|
||||
svc.Cleanup(ctx)
|
||||
assert.Equal(t, 2, len(store.CleanupCalls()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Submit(t *testing.T) {
|
||||
store := StoreMock{
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
svc := NewService(&store, ServiceParams{ImageAPI: "/blah/", EditDuration: time.Millisecond * 100})
|
||||
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
||||
assert.Equal(t, 3, len(store.ResetCleanupTimerCalls()))
|
||||
err := svc.Commit(func() []string { return []string{"id4", "id5"} })
|
||||
assert.NoError(t, err)
|
||||
svc.Submit(func() []string { return []string{"id6", "id7"} })
|
||||
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
||||
svc.Submit(nil)
|
||||
assert.Equal(t, 2, len(store.CommitCalls()))
|
||||
time.Sleep(time.Millisecond * 175)
|
||||
assert.Equal(t, 7, len(store.CommitCalls()))
|
||||
svc.Close(context.TODO())
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
store := StoreMock{
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
svc := NewService(&store, ServiceParams{ImageAPI: "/blah/", EditDuration: time.Millisecond * 100})
|
||||
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
||||
assert.Equal(t, 3, len(store.ResetCleanupTimerCalls()))
|
||||
err := svc.Commit(func() []string { return []string{"id4", "id5"} })
|
||||
assert.NoError(t, err)
|
||||
svc.Submit(func() []string { return []string{"id6", "id7"} })
|
||||
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
||||
svc.Submit(nil)
|
||||
assert.Equal(t, 2, len(store.CommitCalls()))
|
||||
time.Sleep(time.Millisecond * 175)
|
||||
assert.Equal(t, 7, len(store.CommitCalls()))
|
||||
svc.Close(context.TODO())
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Close(t *testing.T) {
|
||||
@@ -173,21 +178,23 @@ func TestService_Close(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_SubmitDelay(t *testing.T) {
|
||||
store := StoreMock{
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
||||
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
||||
time.Sleep(150 * time.Millisecond) // let first batch to pass TTL
|
||||
svc.Submit(func() []string { return []string{"id4", "id5"} })
|
||||
svc.Submit(nil)
|
||||
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, 3, len(store.CommitCalls()))
|
||||
svc.Close(context.TODO())
|
||||
assert.Equal(t, 5, len(store.CommitCalls()))
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
store := StoreMock{
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
||||
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
||||
time.Sleep(150 * time.Millisecond) // let first batch to pass TTL
|
||||
svc.Submit(func() []string { return []string{"id4", "id5"} })
|
||||
svc.Submit(nil)
|
||||
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, 3, len(store.CommitCalls()))
|
||||
svc.Close(context.TODO())
|
||||
assert.Equal(t, 5, len(store.CommitCalls()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Info(t *testing.T) {
|
||||
@@ -203,19 +210,17 @@ func TestService_Info(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_resize(t *testing.T) {
|
||||
// reader is nil
|
||||
resized := resize(nil, 100, 100)
|
||||
assert.Nil(t, resized)
|
||||
t.Run("empty data returns nil", func(t *testing.T) {
|
||||
assert.Nil(t, resize(nil, 100, 100))
|
||||
assert.Nil(t, resize([]byte{}, 100, 100))
|
||||
})
|
||||
|
||||
// negative limit error
|
||||
resized = resize([]byte("some picture bin data"), -1, -1)
|
||||
require.NotNil(t, resized)
|
||||
assert.Equal(t, resized, []byte("some picture bin data"))
|
||||
|
||||
// decode error
|
||||
resized = resize([]byte("invalid image content"), 100, 100)
|
||||
assert.NotNil(t, resized)
|
||||
assert.Equal(t, resized, []byte("invalid image content"))
|
||||
t.Run("non-image bytes are refused", func(t *testing.T) {
|
||||
// previously resize would fall back to the raw bytes on decode failure, letting
|
||||
// attacker-controlled non-image content reach the store. After hardening, refuse.
|
||||
assert.Nil(t, resize([]byte("some picture bin data"), -1, -1))
|
||||
assert.Nil(t, resize([]byte("invalid image content"), 100, 100))
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
file string
|
||||
@@ -230,7 +235,7 @@ func TestService_resize(t *testing.T) {
|
||||
require.NoError(t, err, "can't open test file %s", c.file)
|
||||
|
||||
// no need for resize, image dimensions are smaller than resize limit
|
||||
resized = resize(img, 800, 800)
|
||||
resized := resize(img, 800, 800)
|
||||
assert.NotNil(t, resized, "file %s", c.file)
|
||||
assert.Equal(t, resized, img)
|
||||
|
||||
@@ -246,6 +251,76 @@ func TestService_resize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestService_SaveWithIDShortPayload guards readAndValidateImage from panicking
|
||||
// on a body shorter than 512 bytes — historically it sliced data[:512] without
|
||||
// a bounds check, which would panic before any decode-bomb defense could fire.
|
||||
func TestService_SaveWithIDShortPayload(t *testing.T) {
|
||||
short := []byte("not an image")
|
||||
|
||||
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/", MaxSize: 1500, MaxWidth: 32, MaxHeight: 32}}
|
||||
err := svc.SaveWithID("test_id", bytes.NewReader(short))
|
||||
require.Error(t, err, "short non-image body must return an error, not panic")
|
||||
assert.Contains(t, err.Error(), "file format not allowed")
|
||||
}
|
||||
|
||||
// TestService_SaveWithIDWebP confirms that WebP — listed as an allowed format
|
||||
// in readAndValidateImage — still round-trips through prepareImage now that
|
||||
// resize() runs image.DecodeConfig. Without registering the WebP decoder, a
|
||||
// legitimate WebP upload would fail DecodeConfig and prepareImage would error.
|
||||
func TestService_SaveWithIDWebP(t *testing.T) {
|
||||
webp, err := os.ReadFile("testdata/pixel.webp")
|
||||
require.NoError(t, err)
|
||||
|
||||
// sanity: the fixture must be a well-formed 1x1 WebP that DecodeConfig accepts.
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(webp))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "webp", format)
|
||||
require.Equal(t, 1, cfg.Width)
|
||||
require.Equal(t, 1, cfg.Height)
|
||||
|
||||
store := StoreMock{SaveFunc: func(string, []byte) error { return nil }}
|
||||
svc := Service{store: &store, ServiceParams: ServiceParams{MaxSize: 1500}}
|
||||
|
||||
err = svc.SaveWithID("webp_id", bytes.NewReader(webp))
|
||||
require.NoError(t, err, "valid WebP must round-trip through SaveWithID")
|
||||
assert.Equal(t, 1, len(store.SaveCalls()))
|
||||
assert.Equal(t, webp, store.SaveCalls()[0].Img, "no-resize path must return bytes verbatim")
|
||||
}
|
||||
|
||||
// TestService_resizeRejectsDecompressionBomb verifies the dimension-cap defense.
|
||||
// Builds a tiny GIF that declares 65535x65535 (4 gigapixels) in its logical-screen
|
||||
// header — image.DecodeConfig reads the dimensions, the int64 product overflows
|
||||
// any 32-bit int wrap, and resize must refuse before image.Decode allocates ~17 GB
|
||||
// of pixel memory.
|
||||
func TestService_resizeRejectsDecompressionBomb(t *testing.T) {
|
||||
// minimal GIF87a header with 65535x65535 logical screen, no global color table.
|
||||
// Bytes 6-7 are the little-endian width, 8-9 are the little-endian height.
|
||||
bomb := []byte{
|
||||
'G', 'I', 'F', '8', '7', 'a',
|
||||
0xFF, 0xFF,
|
||||
0xFF, 0xFF,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x3B,
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(bomb))
|
||||
require.NoError(t, err, "bomb header must decode at the config level")
|
||||
assert.Equal(t, 65535, cfg.Width)
|
||||
assert.Equal(t, 65535, cfg.Height)
|
||||
|
||||
assert.Nil(t, resize(bomb, 100, 100), "resize must refuse oversized dimensions before raster decode")
|
||||
assert.Nil(t, resize(bomb, 0, 0), "even with no-resize limits, oversized dims must be refused")
|
||||
|
||||
// integration-level: SaveWithID must reject the same bomb without panicking
|
||||
// or allocating gigabytes of raster memory.
|
||||
store := StoreMock{SaveFunc: func(string, []byte) error { return nil }}
|
||||
svc := Service{store: &store, ServiceParams: ServiceParams{MaxSize: 1500}}
|
||||
err = svc.SaveWithID("bomb_id", bytes.NewReader(bomb))
|
||||
require.Error(t, err, "SaveWithID must reject decompression bomb")
|
||||
assert.Equal(t, 0, len(store.SaveCalls()), "rejected bomb must not be stored")
|
||||
}
|
||||
|
||||
func TestGetProportionalSizes(t *testing.T) {
|
||||
tbl := []struct {
|
||||
inpW, inpH int
|
||||
@@ -284,3 +359,6 @@ func TestService_DoubleClose(*testing.T) {
|
||||
// second call should not result in panic
|
||||
svc.Close(context.TODO())
|
||||
}
|
||||
|
||||
// TestSafeImgContentType now lives in the rest package alongside the SafeImgContentType
|
||||
// helper itself (see backend/app/rest/image_headers_test.go).
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 38 B |
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
@@ -47,12 +48,7 @@ func (m *RestrictedWordsMatcher) Match(siteID, text string) bool {
|
||||
tokens := m.tokenize(text)
|
||||
trie := newWildcardTrie(restrictedWords...)
|
||||
|
||||
for _, token := range tokens {
|
||||
if trie.check(token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.ContainsFunc(tokens, trie.check)
|
||||
}
|
||||
|
||||
func (m *RestrictedWordsMatcher) tokenize(text string) []string {
|
||||
|
||||
@@ -62,7 +62,7 @@ type UserMetaData struct {
|
||||
Until time.Time `json:"until"`
|
||||
} `json:"blocked"`
|
||||
Verified bool `json:"verified"`
|
||||
Details engine.UserDetailEntry `json:"details,omitempty"`
|
||||
Details engine.UserDetailEntry `json:"details"`
|
||||
}
|
||||
|
||||
// PostMetaData keeps info about post flags
|
||||
@@ -552,9 +552,9 @@ func (s *DataStore) HasReplies(comment store.Comment) bool {
|
||||
for _, c := range comments {
|
||||
if c.ParentID != "" && !c.Deleted {
|
||||
if c.ParentID == comment.ID {
|
||||
// When this code is reached, key "comment.ID" is not in cache.
|
||||
// Calling cache.Get on it will put it in cache with 5 minutes TTL.
|
||||
// We call it with empty struct as value as we care about keys and not values.
|
||||
// when this code is reached, key "comment.ID" is not in cache.
|
||||
// calling cache.Get on it will put it in cache with 5 minutes TTL.
|
||||
// we call it with empty struct as value as we care about keys and not values.
|
||||
_, _ = s.repliesCache.Get(comment.ID, func() (struct{}, error) { return struct{}{}, nil })
|
||||
return true
|
||||
}
|
||||
@@ -611,7 +611,7 @@ func (s *DataStore) SetTitle(locator store.Locator, commentID string) (comment s
|
||||
// set title, overwrite the current one
|
||||
title, e := s.TitleExtractor.Get(comment.Locator.URL)
|
||||
if e != nil {
|
||||
return comment, err
|
||||
return comment, e
|
||||
}
|
||||
comment.PostTitle = title
|
||||
comment.Locator = locator
|
||||
@@ -676,12 +676,7 @@ func (s *DataStore) IsAdmin(siteID, userID string) bool {
|
||||
log.Printf("[WARN] can't get admins for %s, %v", siteID, err)
|
||||
return false
|
||||
}
|
||||
for _, a := range admins {
|
||||
if a == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(admins, userID)
|
||||
}
|
||||
|
||||
// IsReadOnly checks if post read-only
|
||||
@@ -743,7 +738,7 @@ func (s *DataStore) SetBlock(siteID, userID string, status bool, ttl time.Durati
|
||||
func (s *DataStore) BlockedUsers(siteID string) (res []store.BlockedUser, err error) {
|
||||
blocked, e := s.Engine.ListFlags(engine.FlagRequest{Locator: store.Locator{SiteID: siteID}, Flag: engine.Blocked})
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("can't get list of blocked users for %s: %w", siteID, err)
|
||||
return nil, fmt.Errorf("can't get list of blocked users for %s: %w", siteID, e)
|
||||
}
|
||||
for _, v := range blocked {
|
||||
res = append(res, v.(store.BlockedUser))
|
||||
@@ -801,7 +796,7 @@ func (s *DataStore) Delete(locator store.Locator, commentID string, mode store.D
|
||||
idsFn := func() []string { // get IDs of all images from the same URL to verify if image from deleted comment was reused
|
||||
comments, e := s.Engine.Find(engine.FindRequest{Locator: locator})
|
||||
if e != nil {
|
||||
log.Printf("[WARN] can't get comments %s text for deleted comment image check, %v", comment.ID, err)
|
||||
log.Printf("[WARN] can't get comments %s text for deleted comment image check, %v", comment.ID, e)
|
||||
return nil
|
||||
}
|
||||
var imgIDs = []string{}
|
||||
@@ -822,7 +817,7 @@ func (s *DataStore) Delete(locator store.Locator, commentID string, mode store.D
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[ERROR] commentImgIDs: %v, pageImgIDs: %v", commentImgIDs, pageImgIDs)
|
||||
log.Printf("[DEBUG] commentImgIDs: %v, pageImgIDs: %v", commentImgIDs, pageImgIDs)
|
||||
|
||||
req := engine.DeleteRequest{Locator: locator, CommentID: commentID, DeleteMode: mode}
|
||||
return s.Engine.Delete(req)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/lgr"
|
||||
@@ -184,7 +185,7 @@ func TestService_Put(t *testing.T) {
|
||||
Text: "test text",
|
||||
User: store.User{ID: "user2", Name: "user name 2"},
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
}
|
||||
_, err := b.Create(comment)
|
||||
require.NoError(t, err)
|
||||
@@ -196,7 +197,7 @@ func TestService_Put(t *testing.T) {
|
||||
Text: "new text",
|
||||
User: store.User{ID: "user3", Name: "user name 3"},
|
||||
Locator: store.Locator{URL: "https://example.com", SiteID: "example"},
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
}
|
||||
|
||||
err = b.Put(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, updatedComment)
|
||||
@@ -212,7 +213,7 @@ func TestService_Put(t *testing.T) {
|
||||
assert.Equal(t, "user name 2", got.User.Name, "should be unaltered")
|
||||
assert.Equal(t, "https://radio-t.com", got.Locator.URL, "should be unaltered")
|
||||
assert.Equal(t, "radio-t", got.Locator.SiteID, "should be unaltered")
|
||||
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local), got.Timestamp, "should be unaltered")
|
||||
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC), got.Timestamp, "should be unaltered")
|
||||
|
||||
}
|
||||
|
||||
@@ -438,13 +439,11 @@ func TestService_VoteAggressive(t *testing.T) {
|
||||
|
||||
// crazy vote +1 as user1
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 1000; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 1000 {
|
||||
wg.Go(func() {
|
||||
_, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
|
||||
UserID: "user1", Val: true})
|
||||
}()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
res, err = b.Last("radio-t", 0, time.Time{}, store.User{ID: "user1"})
|
||||
@@ -458,14 +457,12 @@ func TestService_VoteAggressive(t *testing.T) {
|
||||
assert.Equal(t, 0, len(res[0].VotedIPs), "vote ips hidden")
|
||||
|
||||
// random +1/-1 result should be [0..2]
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
wg.Go(func() {
|
||||
val := rand.Intn(2) > 0
|
||||
_, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
|
||||
UserID: "user1", Val: val})
|
||||
}()
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
res, err = b.Last("radio-t", 0, time.Time{}, store.User{})
|
||||
@@ -492,14 +489,11 @@ func TestService_VoteConcurrent(t *testing.T) {
|
||||
|
||||
// concurrent vote +1 as multiple users for the same comment
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
ii := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range 100 {
|
||||
wg.Go(func() {
|
||||
_, _ = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: res[0].ID,
|
||||
UserID: fmt.Sprintf("user1-%d", ii), Val: true})
|
||||
}()
|
||||
UserID: fmt.Sprintf("user1-%d", i), Val: true})
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
res, err = b.Last("radio-t", 0, time.Time{}, store.User{})
|
||||
@@ -602,7 +596,7 @@ func TestService_RestrictedWords(t *testing.T) {
|
||||
ID: "c-1",
|
||||
ParentID: "id-1",
|
||||
Text: "restricted word",
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name 2"},
|
||||
}
|
||||
@@ -637,12 +631,12 @@ func TestDataStore_AdminStoreErrors(t *testing.T) {
|
||||
ID: "c-1",
|
||||
ParentID: "id-1",
|
||||
Text: "restricted word",
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name 2"},
|
||||
}
|
||||
|
||||
// Key call error
|
||||
// key call error
|
||||
id, err := b.Create(comment)
|
||||
assert.ErrorContainsf(t, err, "mock key err", "should fail with mock error")
|
||||
assert.Empty(t, id)
|
||||
@@ -650,7 +644,7 @@ func TestDataStore_AdminStoreErrors(t *testing.T) {
|
||||
assert.Equal(t, len(as.EnabledCalls()), 0)
|
||||
assert.Equal(t, len(as.OnEventCalls()), 0)
|
||||
|
||||
// Enabled call error
|
||||
// enabled call error
|
||||
badKey = false
|
||||
id, err = b.Create(comment)
|
||||
assert.ErrorContains(t, err, "mock enabled err", "should fail with mock error")
|
||||
@@ -676,7 +670,7 @@ func TestDataStore_AdminStoreErrors(t *testing.T) {
|
||||
assert.Equal(t, len(as.EnabledCalls()), 3)
|
||||
assert.Equal(t, len(as.OnEventCalls()), 2)
|
||||
|
||||
// Admins error
|
||||
// admins error
|
||||
isAdmin := b.IsAdmin("radio-t", "user2")
|
||||
assert.False(t, isAdmin)
|
||||
assert.Equal(t, len(as.AdminsCalls()), 1)
|
||||
@@ -723,34 +717,36 @@ func TestService_VoteSameIP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_VoteSameIPWithDuration(t *testing.T) {
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, AdminStore: admin.NewStaticKeyStore("secret 123"),
|
||||
MaxVotes: -1}
|
||||
b.RestrictSameIPVotes.Enabled = true
|
||||
b.RestrictSameIPVotes.Duration = 500 * time.Millisecond
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, AdminStore: admin.NewStaticKeyStore("secret 123"),
|
||||
MaxVotes: -1}
|
||||
b.RestrictSameIPVotes.Enabled = true
|
||||
b.RestrictSameIPVotes.Duration = 500 * time.Millisecond
|
||||
|
||||
c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user2", UserIP: "123", Val: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, c.Score, "should have 1 score")
|
||||
c, err := b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user2", UserIP: "123", Val: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, c.Score, "should have 1 score")
|
||||
|
||||
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user3", UserIP: "123", Val: true})
|
||||
assert.EqualError(t, err, "the same ip cce61be6e0a692420ae0de31dceca179123c3b8a already voted for id-2")
|
||||
assert.Equal(t, 1, c.Score, "still have 1 score")
|
||||
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user3", UserIP: "123", Val: true})
|
||||
assert.EqualError(t, err, "the same ip cce61be6e0a692420ae0de31dceca179123c3b8a already voted for id-2")
|
||||
assert.Equal(t, 1, c.Score, "still have 1 score")
|
||||
|
||||
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user4", UserIP: "12345", Val: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, c.Score, "have 2 score")
|
||||
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user4", UserIP: "12345", Val: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, c.Score, "have 2 score")
|
||||
|
||||
time.Sleep(501 * time.Millisecond)
|
||||
time.Sleep(501 * time.Millisecond)
|
||||
|
||||
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user3", UserIP: "123", Val: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, c.Score, "have 3 score")
|
||||
c, err = b.Vote(VoteReq{Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, CommentID: "id-2",
|
||||
UserID: "user3", UserIP: "123", Val: true})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, c.Score, "have 3 score")
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Controversy(t *testing.T) {
|
||||
@@ -864,8 +860,6 @@ func TestService_EditCommentDurationFailed(t *testing.T) {
|
||||
require.Equal(t, 2, len(res))
|
||||
assert.Nil(t, res[0].Edit)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
_, err = b.EditComment(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID,
|
||||
EditRequest{Orig: "yyy", Text: "xxx", Summary: "my edit"})
|
||||
assert.Error(t, err)
|
||||
@@ -887,7 +881,7 @@ func TestService_EditCommentReplyFailed(t *testing.T) {
|
||||
ID: "123456",
|
||||
ParentID: "id-1",
|
||||
Text: "some text",
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name 2"},
|
||||
}
|
||||
@@ -911,8 +905,6 @@ func TestService_EditCommentAdmin(t *testing.T) {
|
||||
require.Equal(t, 2, len(res))
|
||||
assert.Nil(t, res[0].Edit)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
_, err = b.EditComment(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, res[0].ID,
|
||||
EditRequest{Orig: "yyy", Text: "xxx", Summary: "my edit", Admin: true})
|
||||
assert.NoError(t, err)
|
||||
@@ -959,7 +951,7 @@ func TestService_Counts(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "123456",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -1130,7 +1122,7 @@ func TestService_HasReplies(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "id-1",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -1140,7 +1132,7 @@ func TestService_HasReplies(t *testing.T) {
|
||||
ID: "c-1",
|
||||
ParentID: "id-1",
|
||||
Text: "some text",
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name 2"},
|
||||
}
|
||||
@@ -1188,87 +1180,89 @@ func TestService_HasReplies(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestService_UserReplies(t *testing.T) {
|
||||
// two comments for https://radio-t.com, no reply
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng,
|
||||
AdminStore: admin.NewStaticStore("secret 123", nil, []string{"user2"}, "user@email.com")}
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// two comments for https://radio-t.com, no reply
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng,
|
||||
AdminStore: admin.NewStaticStore("secret 123", nil, []string{"user2"}, "user@email.com")}
|
||||
|
||||
c1 := store.Comment{
|
||||
ID: "comment-id-1",
|
||||
Text: "test 123",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u1", Name: "developer one u1"},
|
||||
}
|
||||
c2 := store.Comment{
|
||||
ID: "comment-id-2",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u2", Name: "developer one u2"},
|
||||
}
|
||||
c3 := store.Comment{
|
||||
ID: "comment-id-3",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u2", Name: "developer one u3"},
|
||||
}
|
||||
c4 := store.Comment{
|
||||
ID: "comment-id-4",
|
||||
ParentID: "",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u4", Name: "developer one u4"},
|
||||
}
|
||||
c5 := store.Comment{
|
||||
ID: "comment-id-5",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u2", Name: "developer one u2"},
|
||||
}
|
||||
c1 := store.Comment{
|
||||
ID: "comment-id-1",
|
||||
Text: "test 123",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u1", Name: "developer one u1"},
|
||||
}
|
||||
c2 := store.Comment{
|
||||
ID: "comment-id-2",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u2", Name: "developer one u2"},
|
||||
}
|
||||
c3 := store.Comment{
|
||||
ID: "comment-id-3",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u2", Name: "developer one u3"},
|
||||
}
|
||||
c4 := store.Comment{
|
||||
ID: "comment-id-4",
|
||||
ParentID: "",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u4", Name: "developer one u4"},
|
||||
}
|
||||
c5 := store.Comment{
|
||||
ID: "comment-id-5",
|
||||
ParentID: "comment-id-1",
|
||||
Text: "xyz test",
|
||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "radio-t"},
|
||||
User: store.User{ID: "u2", Name: "developer one u2"},
|
||||
}
|
||||
|
||||
_, err := b.Create(c1)
|
||||
require.NoError(t, err)
|
||||
_, err = b.Create(c2)
|
||||
require.NoError(t, err)
|
||||
_, err = b.Create(c3)
|
||||
require.NoError(t, err)
|
||||
_, err = b.Create(c4)
|
||||
require.NoError(t, err)
|
||||
// small sleeps give each Create a unique nanosecond timestamp under synctest's fake clock,
|
||||
// since Bolt keys the "last" bucket by comment timestamp
|
||||
_, err := b.Create(c1)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Nanosecond)
|
||||
_, err = b.Create(c2)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Nanosecond)
|
||||
_, err = b.Create(c3)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Nanosecond)
|
||||
_, err = b.Create(c4)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, err = b.Create(c5)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, err = b.Create(c5)
|
||||
require.NoError(t, err)
|
||||
|
||||
cc, u, err := b.UserReplies("radio-t", "u1", 10, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 3, len(cc), "3 replies to u1")
|
||||
assert.Equal(t, "developer one u1", u)
|
||||
cc, u, err := b.UserReplies("radio-t", "u1", 10, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 3, len(cc), "3 replies to u1")
|
||||
assert.Equal(t, "developer one u1", u)
|
||||
|
||||
// mutex to prevent multiple b.UserReplies calls resulting in data race
|
||||
l := sync.Mutex{}
|
||||
assert.Eventually(t, func() bool {
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
// advance fake clock so c2 and c3 (created 100ms before c5) age past the 299ms window,
|
||||
// leaving only c5 as a recent reply
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
cc, u, err = b.UserReplies("radio-t", "u1", 10, time.Millisecond*299)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "developer one u1", u)
|
||||
return len(cc) == 1
|
||||
}, 300*time.Millisecond, 30*time.Millisecond, "1 reply to u1 in the last 300ms")
|
||||
require.Equal(t, 1, len(cc), "1 reply to u1 in the last 299ms")
|
||||
|
||||
l.Lock()
|
||||
defer l.Unlock()
|
||||
cc, u, err = b.UserReplies("radio-t", "u2", 10, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(cc), "0 replies to u2")
|
||||
assert.Equal(t, "developer one u2", u)
|
||||
cc, u, err = b.UserReplies("radio-t", "u2", 10, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(cc), "0 replies to u2")
|
||||
assert.Equal(t, "developer one u2", u)
|
||||
|
||||
cc, u, err = b.UserReplies("radio-t", "uxxx", 10, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(cc), "0 replies to uxxx")
|
||||
assert.Equal(t, "", u)
|
||||
cc, u, err = b.UserReplies("radio-t", "uxxx", 10, time.Hour)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(cc), "0 replies to uxxx")
|
||||
assert.Equal(t, "", u)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_Find(t *testing.T) {
|
||||
@@ -1286,7 +1280,7 @@ func TestService_Find(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "123456",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
Score: 1,
|
||||
@@ -1322,7 +1316,7 @@ func TestService_FindSince(t *testing.T) {
|
||||
assert.Equal(t, "id-1", res[0].ID)
|
||||
|
||||
res, err = b.FindSince(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "time", store.User{},
|
||||
time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local))
|
||||
time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(res))
|
||||
assert.Equal(t, "id-2", res[0].ID)
|
||||
@@ -1339,7 +1333,7 @@ func TestService_Info(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "123456xyz",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/another", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
}
|
||||
@@ -1365,7 +1359,6 @@ func TestService_Info(t *testing.T) {
|
||||
assert.True(t, info.LastTS.After(info.FirstTS))
|
||||
firstTS := info.FirstTS
|
||||
|
||||
time.Sleep(1 * time.Second) // make post RO in 1sec
|
||||
info, err = b.Info(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://radio-t.com", info.URL)
|
||||
@@ -1408,75 +1401,77 @@ func TestService_Delete(t *testing.T) {
|
||||
func TestService_deleteImagesOnCommentDelete(t *testing.T) {
|
||||
lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc)
|
||||
|
||||
mockStore := image.StoreMock{
|
||||
DeleteFunc: func(string) error { return nil },
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
imgSvc := image.NewService(&mockStore,
|
||||
image.ServiceParams{
|
||||
EditDuration: 50 * time.Millisecond,
|
||||
ImageAPI: "/images/dev/",
|
||||
ProxyAPI: "/non_existent",
|
||||
})
|
||||
defer imgSvc.Close(context.TODO())
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
mockStore := image.StoreMock{
|
||||
DeleteFunc: func(string) error { return nil },
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
imgSvc := image.NewService(&mockStore,
|
||||
image.ServiceParams{
|
||||
EditDuration: 50 * time.Millisecond,
|
||||
ImageAPI: "/images/dev/",
|
||||
ProxyAPI: "/non_existent",
|
||||
})
|
||||
defer imgSvc.Close(context.TODO())
|
||||
|
||||
// two comments for https://radio-t.com
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, EditDuration: 50 * time.Millisecond,
|
||||
AdminStore: admin.NewStaticKeyStore("secret 123"), ImageService: imgSvc}
|
||||
// two comments for https://radio-t.com
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, EditDuration: 50 * time.Millisecond,
|
||||
AdminStore: admin.NewStaticKeyStore("secret 123"), ImageService: imgSvc}
|
||||
|
||||
c := store.Comment{
|
||||
ID: "id-22",
|
||||
Text: `some text <img src="/images/dev/pic1.png"/> xx <img src="/images/dev/pic2.png"/>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Engine.Create(c) // create directly with engine, doesn't call submitImages
|
||||
assert.NoError(t, err)
|
||||
b.submitImages(c)
|
||||
// reply to the first comment with one new image and one existing one
|
||||
c = store.Comment{
|
||||
ID: "id-23",
|
||||
ParentID: "id-22",
|
||||
Text: `some text <img src="/images/dev/pic2.png"/> xx <img src="/images/dev/pic3.png"/>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err = b.Engine.Create(c) // create directly with engine, doesn't call submitImages
|
||||
assert.NoError(t, err)
|
||||
b.submitImages(c)
|
||||
c := store.Comment{
|
||||
ID: "id-22",
|
||||
Text: `some text <img src="/images/dev/pic1.png"/> xx <img src="/images/dev/pic2.png"/>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Engine.Create(c) // create directly with engine, doesn't call submitImages
|
||||
assert.NoError(t, err)
|
||||
b.submitImages(c)
|
||||
// reply to the first comment with one new image and one existing one
|
||||
c = store.Comment{
|
||||
ID: "id-23",
|
||||
ParentID: "id-22",
|
||||
Text: `some text <img src="/images/dev/pic2.png"/> xx <img src="/images/dev/pic3.png"/>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err = b.Engine.Create(c) // create directly with engine, doesn't call submitImages
|
||||
assert.NoError(t, err)
|
||||
b.submitImages(c)
|
||||
|
||||
// verify that images are in staging store
|
||||
assert.Equal(t, 4, len(mockStore.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.ResetCleanupTimerCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[1].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[2].ID)
|
||||
assert.Equal(t, "dev/pic3.png", mockStore.ResetCleanupTimerCalls()[3].ID)
|
||||
time.Sleep(b.EditDuration + 100*time.Millisecond)
|
||||
// verify that they got into the main store
|
||||
assert.Equal(t, 4, len(mockStore.CommitCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.CommitCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[1].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[2].ID)
|
||||
assert.Equal(t, "dev/pic3.png", mockStore.CommitCalls()[3].ID)
|
||||
// verify that images are in staging store
|
||||
assert.Equal(t, 4, len(mockStore.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.ResetCleanupTimerCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[1].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[2].ID)
|
||||
assert.Equal(t, "dev/pic3.png", mockStore.ResetCleanupTimerCalls()[3].ID)
|
||||
time.Sleep(b.EditDuration + 100*time.Millisecond)
|
||||
// verify that they got into the main store
|
||||
assert.Equal(t, 4, len(mockStore.CommitCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.CommitCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[1].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[2].ID)
|
||||
assert.Equal(t, "dev/pic3.png", mockStore.CommitCalls()[3].ID)
|
||||
|
||||
// delete the first comment
|
||||
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-22", store.SoftDelete)
|
||||
assert.NoError(t, err)
|
||||
// verify that images are deleted from the main store
|
||||
assert.Equal(t, 1, len(mockStore.DeleteCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.DeleteCalls()[0].ID)
|
||||
// delete the first comment
|
||||
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-22", store.SoftDelete)
|
||||
assert.NoError(t, err)
|
||||
// verify that images are deleted from the main store
|
||||
assert.Equal(t, 1, len(mockStore.DeleteCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.DeleteCalls()[0].ID)
|
||||
|
||||
// delete the second comment
|
||||
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-23", store.SoftDelete)
|
||||
assert.NoError(t, err)
|
||||
// verify that images are deleted from the main store
|
||||
assert.Equal(t, 3, len(mockStore.DeleteCalls()))
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.DeleteCalls()[1].ID)
|
||||
assert.Equal(t, "dev/pic3.png", mockStore.DeleteCalls()[2].ID)
|
||||
// delete the second comment
|
||||
err = b.Delete(store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"}, "id-23", store.SoftDelete)
|
||||
assert.NoError(t, err)
|
||||
// verify that images are deleted from the main store
|
||||
assert.Equal(t, 3, len(mockStore.DeleteCalls()))
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.DeleteCalls()[1].ID)
|
||||
assert.Equal(t, "dev/pic3.png", mockStore.DeleteCalls()[2].ID)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUser removes all comments from user
|
||||
@@ -1491,7 +1486,7 @@ func TestService_DeleteUser(t *testing.T) {
|
||||
comment := store.Comment{
|
||||
ID: "123456xyz",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
}
|
||||
@@ -1521,7 +1516,7 @@ func TestService_List(t *testing.T) {
|
||||
// add one more for user2
|
||||
comment := store.Comment{
|
||||
ID: "id-3",
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
@@ -1534,13 +1529,13 @@ func TestService_List(t *testing.T) {
|
||||
require.Equal(t, 2, len(res), "2 posts")
|
||||
assert.Equal(t, "https://radio-t.com/2", res[0].URL)
|
||||
assert.Equal(t, 1, res[0].Count)
|
||||
assert.Equal(t, time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local), res[0].FirstTS)
|
||||
assert.Equal(t, time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local), res[0].LastTS)
|
||||
assert.Equal(t, time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC), res[0].FirstTS)
|
||||
assert.Equal(t, time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC), res[0].LastTS)
|
||||
|
||||
assert.Equal(t, "https://radio-t.com", res[1].URL)
|
||||
assert.Equal(t, 2, res[1].Count)
|
||||
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local), res[1].FirstTS)
|
||||
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 23, 0, time.Local), res[1].LastTS)
|
||||
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC), res[1].FirstTS)
|
||||
assert.Equal(t, time.Date(2017, 12, 20, 15, 18, 23, 0, time.UTC), res[1].LastTS)
|
||||
}
|
||||
|
||||
func TestService_Count(t *testing.T) {
|
||||
@@ -1553,7 +1548,7 @@ func TestService_Count(t *testing.T) {
|
||||
// add one more for user2
|
||||
comment := store.Comment{
|
||||
ID: "id-3",
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
@@ -1584,7 +1579,7 @@ func TestService_UserComments(t *testing.T) {
|
||||
// add one more for user2
|
||||
comment := store.Comment{
|
||||
ID: "id-3",
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
@@ -1609,7 +1604,7 @@ func TestService_UserCount(t *testing.T) {
|
||||
// add one more for user2
|
||||
comment := store.Comment{
|
||||
ID: "id-3",
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
@@ -1639,7 +1634,7 @@ func TestService_DeleteAll(t *testing.T) {
|
||||
// add one more for user2
|
||||
comment := store.Comment{
|
||||
ID: "id-3",
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2018, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Locator: store.Locator{URL: "https://radio-t.com/2", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user2", Name: "user name"},
|
||||
@@ -1658,129 +1653,133 @@ func TestService_DeleteAll(t *testing.T) {
|
||||
func TestService_submitImages(t *testing.T) {
|
||||
lgr.Setup(lgr.Debug, lgr.CallerFile, lgr.CallerFunc)
|
||||
|
||||
mockStore := image.StoreMock{
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
imgSvc := image.NewService(&mockStore,
|
||||
image.ServiceParams{
|
||||
EditDuration: 50 * time.Millisecond,
|
||||
ImageAPI: "/images/dev/",
|
||||
ProxyAPI: "/non_existent",
|
||||
})
|
||||
defer imgSvc.Close(context.TODO())
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
mockStore := image.StoreMock{
|
||||
CommitFunc: func(string) error { return nil },
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
imgSvc := image.NewService(&mockStore,
|
||||
image.ServiceParams{
|
||||
EditDuration: 50 * time.Millisecond,
|
||||
ImageAPI: "/images/dev/",
|
||||
ProxyAPI: "/non_existent",
|
||||
})
|
||||
defer imgSvc.Close(context.TODO())
|
||||
|
||||
// two comments for https://radio-t.com
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, EditDuration: 50 * time.Millisecond,
|
||||
AdminStore: admin.NewStaticKeyStore("secret 123"), ImageService: imgSvc}
|
||||
// two comments for https://radio-t.com
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, EditDuration: 50 * time.Millisecond,
|
||||
AdminStore: admin.NewStaticKeyStore("secret 123"), ImageService: imgSvc}
|
||||
|
||||
c := store.Comment{
|
||||
ID: "id-22",
|
||||
Text: `some text <img src="/images/dev/pic1.png"/> xx <img src="/images/dev/pic2.png"/>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Engine.Create(c) // create directly with engine, doesn't call submitImages
|
||||
assert.NoError(t, err)
|
||||
c := store.Comment{
|
||||
ID: "id-22",
|
||||
Text: `some text <img src="/images/dev/pic1.png"/> xx <img src="/images/dev/pic2.png"/>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Engine.Create(c) // create directly with engine, doesn't call submitImages
|
||||
assert.NoError(t, err)
|
||||
|
||||
b.submitImages(c)
|
||||
assert.Equal(t, 2, len(mockStore.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.ResetCleanupTimerCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[1].ID)
|
||||
time.Sleep(b.EditDuration + 100*time.Millisecond)
|
||||
assert.Equal(t, 2, len(mockStore.CommitCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.CommitCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[1].ID)
|
||||
b.submitImages(c)
|
||||
assert.Equal(t, 2, len(mockStore.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.ResetCleanupTimerCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.ResetCleanupTimerCalls()[1].ID)
|
||||
time.Sleep(b.EditDuration + 100*time.Millisecond)
|
||||
assert.Equal(t, 2, len(mockStore.CommitCalls()))
|
||||
assert.Equal(t, "dev/pic1.png", mockStore.CommitCalls()[0].ID)
|
||||
assert.Equal(t, "dev/pic2.png", mockStore.CommitCalls()[1].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ResubmitStagingImages(t *testing.T) {
|
||||
mockStore := image.StoreMock{
|
||||
InfoFunc: func() (image.StoreInfo, error) {
|
||||
return image.StoreInfo{FirstStagingImageTS: time.Time{}.Add(time.Second)}, nil
|
||||
},
|
||||
CommitFunc: func(string) error {
|
||||
return nil
|
||||
},
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
imgSvc := image.NewService(&mockStore,
|
||||
image.ServiceParams{
|
||||
EditDuration: 10 * time.Millisecond,
|
||||
ImageAPI: "http://127.0.0.1:8080/api/v1/picture/",
|
||||
ProxyAPI: "http://127.0.0.1:8080/api/v1/img",
|
||||
})
|
||||
defer imgSvc.Close(context.TODO())
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
mockStore := image.StoreMock{
|
||||
InfoFunc: func() (image.StoreInfo, error) {
|
||||
return image.StoreInfo{FirstStagingImageTS: time.Time{}.Add(time.Second)}, nil
|
||||
},
|
||||
CommitFunc: func(string) error {
|
||||
return nil
|
||||
},
|
||||
ResetCleanupTimerFunc: func(string) error { return nil },
|
||||
}
|
||||
imgSvc := image.NewService(&mockStore,
|
||||
image.ServiceParams{
|
||||
EditDuration: 10 * time.Millisecond,
|
||||
ImageAPI: "http://127.0.0.1:8080/api/v1/picture/",
|
||||
ProxyAPI: "http://127.0.0.1:8080/api/v1/img",
|
||||
})
|
||||
defer imgSvc.Close(context.TODO())
|
||||
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, EditDuration: 10 * time.Millisecond, ImageService: imgSvc}
|
||||
eng, teardown := prepStoreEngine(t)
|
||||
defer teardown()
|
||||
b := DataStore{Engine: eng, EditDuration: 10 * time.Millisecond, ImageService: imgSvc}
|
||||
|
||||
// create comment with three images without preparing it properly
|
||||
comment := store.Comment{
|
||||
ID: "id-0",
|
||||
Text: `<img src="http://127.0.0.1:8080/api/v1/picture/dev_user/bqf122eq9r8ad657n3ng" alt="startrails_01.jpg"><br/>
|
||||
// create comment with three images without preparing it properly
|
||||
comment := store.Comment{
|
||||
ID: "id-0",
|
||||
Text: `<img src="http://127.0.0.1:8080/api/v1/picture/dev_user/bqf122eq9r8ad657n3ng" alt="startrails_01.jpg"><br/>
|
||||
<img src="http://127.0.0.1:8080/api/v1/picture/dev_user/bqf321eq9r8ad657n3ng" alt="cat.png"><br/>
|
||||
<img src="http://127.0.0.1:8080/api/v1/img?src=aHR0cHM6Ly9ob21lcGFnZXMuY2FlLndpc2MuZWR1L35lY2U1MzMvaW1hZ2VzL2JvYXQucG5n" alt="cat.png"><br/>
|
||||
<img src="https://homepages.cae.wisc.edu/~ece533/images/boat.png" alt="boat.png">`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Engine.Create(comment)
|
||||
require.NoError(t, err)
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
_, err := b.Engine.Create(comment)
|
||||
require.NoError(t, err)
|
||||
|
||||
// resubmit single comment with three images, of which two are in staging storage
|
||||
err = b.ResubmitStagingImages([]string{"radio-t"})
|
||||
assert.NoError(t, err)
|
||||
// resubmit single comment with three images, of which two are in staging storage
|
||||
err = b.ResubmitStagingImages([]string{"radio-t"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// wait for Submit goroutine to commit image
|
||||
time.Sleep(b.EditDuration + time.Millisecond*100)
|
||||
// wait for Submit goroutine to commit image
|
||||
time.Sleep(b.EditDuration + time.Millisecond*100)
|
||||
|
||||
assert.Equal(t, 1, len(mockStore.InfoCalls()))
|
||||
assert.Equal(t, 3, len(mockStore.CommitCalls()))
|
||||
assert.Equal(t, 1, len(mockStore.InfoCalls()))
|
||||
assert.Equal(t, 3, len(mockStore.CommitCalls()))
|
||||
|
||||
// empty answer
|
||||
mockStoreEmpty := image.StoreMock{InfoFunc: func() (image.StoreInfo, error) {
|
||||
return image.StoreInfo{FirstStagingImageTS: time.Time{}}, nil
|
||||
}}
|
||||
imgSvcEmpty := image.NewService(&mockStoreEmpty,
|
||||
image.ServiceParams{
|
||||
EditDuration: 10 * time.Millisecond,
|
||||
ImageAPI: "http://127.0.0.1:8080/api/v1/picture/",
|
||||
})
|
||||
defer imgSvcEmpty.Close(context.TODO())
|
||||
bEmpty := DataStore{Engine: eng, EditDuration: 10 * time.Millisecond, ImageService: imgSvcEmpty}
|
||||
// empty answer
|
||||
mockStoreEmpty := image.StoreMock{InfoFunc: func() (image.StoreInfo, error) {
|
||||
return image.StoreInfo{FirstStagingImageTS: time.Time{}}, nil
|
||||
}}
|
||||
imgSvcEmpty := image.NewService(&mockStoreEmpty,
|
||||
image.ServiceParams{
|
||||
EditDuration: 10 * time.Millisecond,
|
||||
ImageAPI: "http://127.0.0.1:8080/api/v1/picture/",
|
||||
})
|
||||
defer imgSvcEmpty.Close(context.TODO())
|
||||
bEmpty := DataStore{Engine: eng, EditDuration: 10 * time.Millisecond, ImageService: imgSvcEmpty}
|
||||
|
||||
// resubmit receive empty timestamp and should do nothing )
|
||||
err = bEmpty.ResubmitStagingImages([]string{"radio-t", "non_existent"})
|
||||
assert.NoError(t, err)
|
||||
// resubmit receive empty timestamp and should do nothing )
|
||||
err = bEmpty.ResubmitStagingImages([]string{"radio-t", "non_existent"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, len(mockStore.InfoCalls()))
|
||||
assert.Equal(t, 1, len(mockStore.InfoCalls()))
|
||||
|
||||
// error from image storage
|
||||
mockStoreError := image.StoreMock{InfoFunc: func() (image.StoreInfo, error) {
|
||||
return image.StoreInfo{}, fmt.Errorf("mock_err")
|
||||
}}
|
||||
imgSvcError := image.NewService(&mockStoreError,
|
||||
image.ServiceParams{
|
||||
EditDuration: 10 * time.Millisecond,
|
||||
ImageAPI: "http://127.0.0.1:8080/api/v1/picture/",
|
||||
})
|
||||
defer imgSvcError.Close(context.TODO())
|
||||
bError := DataStore{Engine: eng, EditDuration: 10 * time.Millisecond, ImageService: imgSvcError}
|
||||
// error from image storage
|
||||
mockStoreError := image.StoreMock{InfoFunc: func() (image.StoreInfo, error) {
|
||||
return image.StoreInfo{}, fmt.Errorf("mock_err")
|
||||
}}
|
||||
imgSvcError := image.NewService(&mockStoreError,
|
||||
image.ServiceParams{
|
||||
EditDuration: 10 * time.Millisecond,
|
||||
ImageAPI: "http://127.0.0.1:8080/api/v1/picture/",
|
||||
})
|
||||
defer imgSvcError.Close(context.TODO())
|
||||
bError := DataStore{Engine: eng, EditDuration: 10 * time.Millisecond, ImageService: imgSvcError}
|
||||
|
||||
// resubmit will receive error from image storage and should return it
|
||||
err = bError.ResubmitStagingImages([]string{"radio-t"})
|
||||
assert.EqualError(t, err, "mock_err")
|
||||
// resubmit will receive error from image storage and should return it
|
||||
err = bError.ResubmitStagingImages([]string{"radio-t"})
|
||||
assert.EqualError(t, err, "mock_err")
|
||||
|
||||
assert.Equal(t, 1, len(mockStore.InfoCalls()))
|
||||
assert.Equal(t, 3, len(mockStore.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, "dev_user/bqf122eq9r8ad657n3ng", mockStore.ResetCleanupTimerCalls()[0].ID)
|
||||
assert.Equal(t, "dev_user/bqf321eq9r8ad657n3ng", mockStore.ResetCleanupTimerCalls()[1].ID)
|
||||
assert.Equal(t, "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e", mockStore.ResetCleanupTimerCalls()[2].ID)
|
||||
assert.Equal(t, 1, len(mockStore.InfoCalls()))
|
||||
assert.Equal(t, 3, len(mockStore.ResetCleanupTimerCalls()))
|
||||
assert.Equal(t, "dev_user/bqf122eq9r8ad657n3ng", mockStore.ResetCleanupTimerCalls()[0].ID)
|
||||
assert.Equal(t, "dev_user/bqf321eq9r8ad657n3ng", mockStore.ResetCleanupTimerCalls()[1].ID)
|
||||
assert.Equal(t, "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e", mockStore.ResetCleanupTimerCalls()[2].ID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_ResubmitStagingImages_EngineError(t *testing.T) {
|
||||
@@ -1808,7 +1807,7 @@ func TestService_ResubmitStagingImages_EngineError(t *testing.T) {
|
||||
site2Req := engine.FindRequest{Locator: store.Locator{SiteID: "site2", URL: ""}, Sort: "time", Since: time.Time{}.Add(time.Second)}
|
||||
b := DataStore{Engine: &engineMock, EditDuration: 10 * time.Millisecond, ImageService: imgSvc}
|
||||
|
||||
// One call without error and one with error
|
||||
// one call without error and one with error
|
||||
err := b.ResubmitStagingImages([]string{"site1", "site2"})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "problem finding comments for site site2: mockError")
|
||||
@@ -1892,7 +1891,7 @@ func Benchmark_ServiceCreate(b *testing.B) {
|
||||
comment := store.Comment{
|
||||
ID: "id-" + strconv.Itoa(i),
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -1939,7 +1938,7 @@ func prepStoreEngine(t *testing.T) (e engine.Interface, teardown func()) {
|
||||
comment := store.Comment{
|
||||
ID: "id-1",
|
||||
Text: `some text, <a href="http://radio-t.com">link</a>`,
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 22, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
@@ -1949,7 +1948,7 @@ func prepStoreEngine(t *testing.T) (e engine.Interface, teardown func()) {
|
||||
comment = store.Comment{
|
||||
ID: "id-2",
|
||||
Text: "some text2",
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.Local),
|
||||
Timestamp: time.Date(2017, 12, 20, 15, 18, 23, 0, time.UTC),
|
||||
Locator: store.Locator{URL: "https://radio-t.com", SiteID: "radio-t"},
|
||||
User: store.User{ID: "user1", Name: "user name"},
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import (
|
||||
"github.com/go-pkgz/syncs"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/safehttp"
|
||||
)
|
||||
|
||||
func TestTitle_GetTitle(t *testing.T) {
|
||||
@@ -61,7 +63,7 @@ func TestTitle_Get(t *testing.T) {
|
||||
_, err = ex.Get(ts.URL + "/bad")
|
||||
require.Error(t, err)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
r, err := ex.Get(ts.URL + "/good")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "blah 123", r)
|
||||
@@ -70,9 +72,9 @@ func TestTitle_Get(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTitle_GetConcurrent(t *testing.T) {
|
||||
body := ""
|
||||
for n := 0; n < 1000; n++ {
|
||||
body += "something something blah blah\n"
|
||||
var body strings.Builder
|
||||
for range 1000 {
|
||||
body.WriteString("something something blah blah\n")
|
||||
}
|
||||
ex := NewTitleExtractor(http.Client{Timeout: 5 * time.Second}, []string{"127.0.0.1"})
|
||||
defer ex.Close()
|
||||
@@ -80,7 +82,7 @@ func TestTitle_GetConcurrent(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.String(), "/good") {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
_, err := fmt.Fprintf(w, "<html><title>blah 123 %s</title><body>%s</body></html>", r.URL.String(), body)
|
||||
_, err := fmt.Fprintf(w, "<html><title>blah 123 %s</title><body>%s</body></html>", r.URL.String(), body.String())
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
@@ -90,12 +92,11 @@ func TestTitle_GetConcurrent(t *testing.T) {
|
||||
|
||||
g := syncs.NewSizedGroup(10)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
ii := i
|
||||
for i := range 100 {
|
||||
g.Go(func(_ context.Context) {
|
||||
title, err := ex.Get(ts.URL + "/good/" + strconv.Itoa(ii))
|
||||
title, err := ex.Get(ts.URL + "/good/" + strconv.Itoa(i))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "blah 123 "+"/good/"+strconv.Itoa(ii), title)
|
||||
assert.Equal(t, "blah 123 "+"/good/"+strconv.Itoa(i), title)
|
||||
})
|
||||
}
|
||||
g.Wait()
|
||||
@@ -115,7 +116,7 @@ func TestTitle_GetFailed(t *testing.T) {
|
||||
_, err := ex.Get(ts.URL + "/bad")
|
||||
require.Error(t, err)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
for range 100 {
|
||||
r, err := ex.Get(ts.URL + "/bad")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", r)
|
||||
@@ -129,3 +130,41 @@ func TestTitle_DoubleClosed(t *testing.T) {
|
||||
// second call should not result in panic
|
||||
assert.NoError(t, ex.Close())
|
||||
}
|
||||
|
||||
// TestTitle_GetBlocksPrivateIPViaSafeTransport reproduces the SSRF in TitleExtractor.
|
||||
// In production (cmd/server.go) the TitleExtractor receives the comment's Locator.URL
|
||||
// straight from the user JSON body. The domain allowlist alone is not enough — a
|
||||
// hostname suffix-matching an allowed domain can resolve to a private IP (DNS rebinding)
|
||||
// or an attacker can list 127.0.0.1 directly when AllowedHosts is empty.
|
||||
//
|
||||
// The fix is to wrap the http.Client with safehttp.Transport at construction time,
|
||||
// matching what the image proxy already does. This test asserts the safehttp transport
|
||||
// is honored by the title fetcher: even though "127.0.0.1" is in the allowed-domains
|
||||
// list, the dialer refuses to connect to a private address.
|
||||
//
|
||||
// As a control, the second sub-test shows the same setup WITHOUT safehttp.Transport
|
||||
// happily fetches the page — demonstrating the original vulnerability.
|
||||
func TestTitle_GetBlocksPrivateIPViaSafeTransport(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><title>secret</title></html>`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("with safehttp transport: blocked", func(t *testing.T) {
|
||||
client := http.Client{Timeout: 2 * time.Second, Transport: safehttp.Transport()}
|
||||
ex := NewTitleExtractor(client, []string{"127.0.0.1"})
|
||||
defer ex.Close()
|
||||
_, err := ex.Get(ts.URL)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "access to private address is not allowed")
|
||||
})
|
||||
|
||||
t.Run("control: default transport leaks", func(t *testing.T) {
|
||||
client := http.Client{Timeout: 2 * time.Second} // no safehttp.Transport — vulnerable
|
||||
ex := NewTitleExtractor(client, []string{"127.0.0.1"})
|
||||
defer ex.Close()
|
||||
title, err := ex.Get(ts.URL)
|
||||
require.NoError(t, err, "without safehttp.Transport the SSRF succeeds — this is the bug")
|
||||
assert.Equal(t, "secret", title)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ func (t *Tree) limit(limit int, offsetID string) {
|
||||
}
|
||||
}
|
||||
|
||||
if start == len(t.Nodes) { // If the start index is beyond the available nodes, clear the nodes
|
||||
if start == len(t.Nodes) { // if the start index is beyond the available nodes, clear the nodes
|
||||
t.Nodes = []*Node{}
|
||||
return
|
||||
}
|
||||
@@ -198,28 +198,28 @@ func (t *Tree) limit(limit int, offsetID string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Traverse and limit the number of top-level nodes, including their replies
|
||||
// traverse and limit the number of top-level nodes, including their replies
|
||||
limitedNodes := []*Node{}
|
||||
commentsCount := 0
|
||||
|
||||
for _, node := range t.Nodes {
|
||||
repliesCount := countReplies(node) + 1 // Count this node and its replies
|
||||
repliesCount := countReplies(node) + 1 // count this node and its replies
|
||||
|
||||
// If the limit is already reached or exceeded, calculate countLeft and move to the next node
|
||||
// if the limit is already reached or exceeded, calculate countLeft and move to the next node
|
||||
if commentsCount >= limit {
|
||||
t.countLeft += repliesCount
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we just exceeded the limit and there are already some nodes in the list,
|
||||
// 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 {
|
||||
t.countLeft += repliesCount
|
||||
commentsCount = limit // Adjust commentsCount to stop checking limit for the next nodes
|
||||
commentsCount = limit // adjust commentsCount to stop checking limit for the next nodes
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the node and its replies to the list
|
||||
// add the node and its replies to the list
|
||||
limitedNodes = append(limitedNodes, node)
|
||||
commentsCount += repliesCount
|
||||
}
|
||||
@@ -232,8 +232,8 @@ func (t *Tree) limit(limit int, offsetID string) {
|
||||
func countReplies(node *Node) int {
|
||||
count := 0
|
||||
for _, reply := range node.Replies {
|
||||
count++ // Count the reply itself
|
||||
count += countReplies(reply) // Recursively count its replies
|
||||
count++ // count the reply itself
|
||||
count += countReplies(reply) // recursively count its replies
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
+22
-22
@@ -1,23 +1,23 @@
|
||||
module github.com/umputun/remark42/backend
|
||||
|
||||
go 1.25
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Depado/bfchroma/v2 v2.0.0
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/alecthomas/chroma/v2 v2.21.1
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/alecthomas/chroma/v2 v2.24.1
|
||||
github.com/didip/tollbooth/v8 v8.0.1
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
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.1
|
||||
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.1
|
||||
github.com/go-pkgz/lgr v0.12.3
|
||||
github.com/go-pkgz/notify v1.3.0
|
||||
github.com/go-pkgz/repeater/v2 v2.2.0
|
||||
github.com/go-pkgz/rest v1.20.6
|
||||
github.com/go-pkgz/rest v1.21.0
|
||||
github.com/go-pkgz/syncs v1.3.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
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
|
||||
@@ -30,9 +30,10 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/net v0.48.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/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -42,8 +43,7 @@ require (
|
||||
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // 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
|
||||
@@ -54,20 +54,20 @@ require (
|
||||
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.2 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // 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.17.2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.19.0 // indirect
|
||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d // indirect
|
||||
github.com/slack-go/slack v0.17.3 // indirect
|
||||
github.com/slack-go/slack v0.23.1 // 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.6 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // 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
|
||||
)
|
||||
|
||||
+46
-42
@@ -2,14 +2,14 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8=
|
||||
github.com/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
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.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
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/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=
|
||||
@@ -32,24 +32,22 @@ 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/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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
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.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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.1 h1:CBH3Z6ovLT51Nx9TkBcu2L8Dd/xwL6CgJcgqUnC2isQ=
|
||||
github.com/go-pkgz/auth/v2 v2.1.1/go.mod h1:9LwzESczjMavmXNZo1XhYpfYdKWtoCbXt/ZIi0GTvF0=
|
||||
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=
|
||||
@@ -58,24 +56,24 @@ 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.1 h1:8GVfG2rSARq3Eaj5PP158rtBR2LHVGkwioIkQBGbvKg=
|
||||
github.com/go-pkgz/lgr v0.12.1/go.mod h1:A4AxjOthFVFK6jRnVYMeusno5SeDAxcLVHd0kI/lN/Y=
|
||||
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/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.20.6 h1:O/IhQ3I2cS4bJYvL1TLcy63w2OcXZTTBG3R+wTIqPS4=
|
||||
github.com/go-pkgz/rest v1.20.6/go.mod h1:NY+MX1is2kJckJt+nHDNovS/5j9jmF4yQuSno4qg7XU=
|
||||
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-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.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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=
|
||||
@@ -108,8 +106,10 @@ 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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
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/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=
|
||||
@@ -118,14 +118,14 @@ github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
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.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
|
||||
github.com/montanaflynn/stats v0.9.0/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.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
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/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,8 +138,8 @@ 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.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
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/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=
|
||||
@@ -189,10 +189,14 @@ github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDf
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
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=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
@@ -201,10 +205,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
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=
|
||||
@@ -219,10 +223,10 @@ 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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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/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=
|
||||
@@ -230,8 +234,8 @@ 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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=
|
||||
@@ -243,8 +247,8 @@ 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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
@@ -264,8 +268,8 @@ 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
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=
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ Syntax-wise, it is as close as possible to jQuery, with the same function names
|
||||
|
||||
Required Go version:
|
||||
|
||||
* Starting with version `v1.12.0` of goquery, Go 1.25+ is required due to its dependencies.
|
||||
* Starting with version `v1.11.0` of goquery, Go 1.24+ is required due to its dependencies.
|
||||
* Starting with version `v1.10.0` of goquery, Go 1.23+ is required due to the use of function-based iterators.
|
||||
* For `v1.9.0` of goquery, Go 1.18+ is required due to the use of generics.
|
||||
@@ -47,6 +48,7 @@ Ongoing goquery development is tested on the latest 2 versions of Go.
|
||||
|
||||
**Note that goquery's API is now stable, and will not break.**
|
||||
|
||||
* **2026-03-15 (v1.12.0)** : Update `go.mod` dependencies, add go1.26 to the test matrix, **goquery now requires Go version 1.25+**.
|
||||
* **2025-11-16 (v1.11.0)** : Update `go.mod` dependencies, add go1.25 to the test matrix, **goquery now requires Go version 1.24+**.
|
||||
* **2025-04-11 (v1.10.3)** : Update `go.mod` dependencies, small optimization (thanks [@myxzlpltk](https://github.com/myxzlpltk)).
|
||||
* **2025-02-13 (v1.10.2)** : Update `go.mod` dependencies, add go1.24 to the test matrix.
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
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.
|
||||
|
||||
Language definitions are XML files defined in ./lexers/embedded/*.xml.
|
||||
|
||||
Styles/themes are defined in ./styles/*.xml.
|
||||
|
||||
The CLI can be run with `chroma`.
|
||||
|
||||
The web playground can be run with `chromad --csrf-key=moo`. It blocks, so should generally be run in the background. It also does not hot reload, so has to be manually restarted. The playground has two modes - for local development it uses the server itself to render, while for production running `just chromad` will compile ./cmd/libchromawasm into a WASM module that is bundled into `chromad`.
|
||||
+99
@@ -17,3 +17,102 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
// formatters/svg/font_liberation_mono.go
|
||||
|
||||
Digitized data copyright (c) 2010 Google Corporation
|
||||
with Reserved Font Arimo, Tinos and Cousine.
|
||||
Copyright (c) 2012 Red Hat, Inc.
|
||||
with Reserved Font Name Liberation.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
# Multi-stage Dockerfile for chromad Go application using Hermit-managed tools
|
||||
|
||||
# Build stage
|
||||
FROM ubuntu:24.04 AS builder
|
||||
FROM ubuntu:26.04 AS builder
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
git \
|
||||
make \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -25,8 +24,8 @@ ENV CGO_ENABLED=0
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=amd64
|
||||
|
||||
# Build the application using make
|
||||
RUN make build/chromad
|
||||
# Build the application using just
|
||||
RUN just chromad
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.23 AS runtime
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
set positional-arguments := true
|
||||
set shell := ["bash", "-c"]
|
||||
|
||||
version := `git describe --tags --dirty --always`
|
||||
export GOOS := env("GOOS", "linux")
|
||||
export GOARCH := env("GOARCH", "amd64")
|
||||
|
||||
_help:
|
||||
@just -l
|
||||
|
||||
# Generate README.md from lexer definitions
|
||||
readme:
|
||||
#!/usr/bin/env bash
|
||||
GOOS= GOARCH= ./table.py
|
||||
|
||||
# Generate tokentype_string.go
|
||||
tokentype-string:
|
||||
go generate
|
||||
|
||||
# Format JavaScript files
|
||||
format-js:
|
||||
biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js
|
||||
|
||||
# Build chromad binary
|
||||
chromad: wasm-exec chroma-wasm
|
||||
#!/usr/bin/env bash
|
||||
rm -rf build
|
||||
mk cmd/chromad/static/index.min.js : cmd/chromad/static/{index,chroma}.js -- \
|
||||
esbuild --platform=browser --format=esm --bundle cmd/chromad/static/index.js --minify --external:./wasm_exec.js --outfile=cmd/chromad/static/index.min.js
|
||||
mk cmd/chromad/static/index.min.css : cmd/chromad/static/index.css -- \
|
||||
esbuild --bundle cmd/chromad/static/index.css --minify --outfile=cmd/chromad/static/index.min.css
|
||||
cd cmd/chromad && CGOENABLED=0 go build -ldflags="-X 'main.version={{ version }}'" -o ../../build/chromad .
|
||||
|
||||
# Copy wasm_exec.js from TinyGo
|
||||
wasm-exec:
|
||||
#!/usr/bin/env bash
|
||||
tinygoroot=$(tinygo env TINYGOROOT)
|
||||
mk cmd/chromad/static/wasm_exec.js : "$tinygoroot/targets/wasm_exec.js" -- \
|
||||
install -m644 "$tinygoroot/targets/wasm_exec.js" cmd/chromad/static/wasm_exec.js
|
||||
|
||||
# Build WASM binary
|
||||
chroma-wasm:
|
||||
#!/usr/bin/env bash
|
||||
if type tinygo > /dev/null 2>&1; then
|
||||
mk cmd/chromad/static/chroma.wasm : cmd/libchromawasm/main.go -- \
|
||||
tinygo build -no-debug -target wasm -o cmd/chromad/static/chroma.wasm cmd/libchromawasm/main.go
|
||||
else
|
||||
mk cmd/chromad/static/chroma.wasm : cmd/libchromawasm/main.go -- \
|
||||
GOOS=js GOARCH=wasm go build -o cmd/chromad/static/chroma.wasm cmd/libchromawasm/main.go
|
||||
fi
|
||||
|
||||
# Upload chromad to server
|
||||
upload: chromad
|
||||
scp build/chromad root@swapoff.org:
|
||||
ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
.PHONY: chromad upload all
|
||||
|
||||
VERSION ?= $(shell git describe --tags --dirty --always)
|
||||
export GOOS ?= linux
|
||||
export GOARCH ?= amd64
|
||||
|
||||
all: README.md tokentype_string.go
|
||||
|
||||
README.md: lexers/*.go lexers/embedded/*.xml
|
||||
GOOS= GOARCH= ./table.py
|
||||
|
||||
tokentype_string.go: types.go
|
||||
go generate
|
||||
|
||||
.PHONY: format-js
|
||||
format-js:
|
||||
biome format --write cmd/chromad/static/{index.js,chroma.js}
|
||||
|
||||
.PHONY: chromad
|
||||
chromad: build/chromad
|
||||
|
||||
build/chromad: $(shell find cmd/chromad -name '*.go' -o -name '*.html' -o -name '*.css' -o -name '*.js') \
|
||||
cmd/chromad/static/wasm_exec.js \
|
||||
cmd/chromad/static/chroma.wasm
|
||||
rm -rf build
|
||||
esbuild --platform=node --bundle cmd/chromad/static/index.js --minify --outfile=cmd/chromad/static/index.min.js
|
||||
esbuild --bundle cmd/chromad/static/index.css --minify --outfile=cmd/chromad/static/index.min.css
|
||||
(export CGOENABLED=0 ; go build -C cmd/chromad -ldflags="-X 'main.version=$(VERSION)'" -o ../../build/chromad .)
|
||||
|
||||
cmd/chromad/static/wasm_exec.js: $(shell tinygo env TINYGOROOT)/targets/wasm_exec.js
|
||||
install -m644 $< $@
|
||||
|
||||
cmd/chromad/static/chroma.wasm: $(shell git ls-files | grep '\.go|\.xml')
|
||||
if type tinygo > /dev/null; then \
|
||||
tinygo build -no-debug -target wasm -o $@ cmd/libchromawasm/main.go; \
|
||||
else \
|
||||
GOOS=js GOARCH=wasm go build -o $@ cmd/libchromawasm/main.go; \
|
||||
fi
|
||||
|
||||
upload: build/chromad
|
||||
scp build/chromad root@swapoff.org: && \
|
||||
ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
|
||||
+8
-8
@@ -36,25 +36,25 @@ translators for Pygments lexers and styles.
|
||||
|
||||
| Prefix | Language
|
||||
| :----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
| A | ABAP, ABNF, ActionScript, ActionScript 3, Ada, Agda, AL, Alloy, 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, 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
|
||||
| F | Factor, Fennel, Fish, Forth, Fortran, FortranFixed, FSharp
|
||||
| G | GAS, GDScript, GDScript3, Gemtext, Genshi, Genshi HTML, Genshi Text, Gherkin, Gleam, GLSL, Gnuplot, Go, Go HTML Template, Go Template, Go Text Template, GraphQL, Groff, Groovy
|
||||
| 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
|
||||
| M | Makefile, Mako, markdown, Mason, Materialize SQL dialect, Mathematica, Matlab, MCFunction, Meson, Metal, MiniZinc, MLIR, Modelica, Modula-2, Mojo, MonkeyC, MoonScript, MorrowindScript, Myghty, MySQL
|
||||
| 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
|
||||
| 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, SPARQL, SQL, SquidConf, Standard ML, stas, Stylus, Svelte, Swift, SYSTEMD, systemverilog
|
||||
| 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
|
||||
| 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
|
||||
@@ -226,11 +226,11 @@ formatter outputs raw tokens. The latter is useful for debugging lexers.
|
||||
### Styles
|
||||
|
||||
Chroma styles are defined in XML. The style entries use the
|
||||
[same syntax](http://pygments.org/docs/styles/) as Pygments.
|
||||
|
||||
All Pygments styles have been converted to Chroma using the `_tools/style.py`
|
||||
[same syntax](http://pygments.org/docs/styles/) as Pygments. All Pygments styles have been converted to Chroma using the `_tools/style.py`
|
||||
script.
|
||||
|
||||
Style names are case-insensitive. For example, `monokai` and `Monokai` are treated as the same style.
|
||||
|
||||
When you work with one of [Chroma's styles](https://github.com/alecthomas/chroma/tree/master/styles),
|
||||
know that the `Background` token type provides the default style for tokens. It does so
|
||||
by defining a foreground color and background color.
|
||||
|
||||
+1
@@ -528,6 +528,7 @@ func (f *Formatter) styleToCSS(style *chroma.Style) map[chroma.TokenType]string
|
||||
}
|
||||
classes[chroma.Background] += `;` + f.tabWidthStyle()
|
||||
classes[chroma.PreWrapper] += classes[chroma.Background]
|
||||
classes[chroma.PreWrapper] += ` -webkit-text-size-adjust: none;`
|
||||
// Make PreWrapper a grid to show highlight style with full width.
|
||||
if len(f.highlightRanges) > 0 && f.customCSS[chroma.PreWrapper] == `` {
|
||||
classes[chroma.PreWrapper] += `display: grid;`
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
<lexer>
|
||||
<config>
|
||||
<name>AMPL</name>
|
||||
<alias>ampl</alias>
|
||||
<filename>*.mod</filename>
|
||||
<filename>*.run</filename>
|
||||
<mime_type>text/x-ampl</mime_type>
|
||||
<case_insensitive>false</case_insensitive>
|
||||
<dot_all>true</dot_all>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
<!-- Whitespace -->
|
||||
<rule pattern="\s+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
|
||||
<!-- Comments: '#' to end-of-line and C-style comments -->
|
||||
<rule pattern="#[^\n]*">
|
||||
<token type="Comment"/>
|
||||
</rule>
|
||||
<rule pattern="/\*">
|
||||
<token type="Comment"/>
|
||||
<push state="comment"/>
|
||||
</rule>
|
||||
|
||||
<!-- Strings -->
|
||||
<rule pattern=""(\\.|[^\\"])*"">
|
||||
<token type="LiteralString"/>
|
||||
</rule>
|
||||
<rule pattern="'(\\.|[^\\'])*'">
|
||||
<token type="LiteralString"/>
|
||||
</rule>
|
||||
|
||||
<!-- Numbers -->
|
||||
<rule pattern="\b\d+\.\d*([eE][+-]?\d+)?\b">
|
||||
<token type="LiteralNumberFloat"/>
|
||||
</rule>
|
||||
<rule pattern="\b\d+[eE][+-]?\d+\b">
|
||||
<token type="LiteralNumberFloat"/>
|
||||
</rule>
|
||||
<rule pattern="\b\d+\b">
|
||||
<token type="LiteralNumberInteger"/>
|
||||
</rule>
|
||||
|
||||
<!-- Commands / Reserved keywords -->
|
||||
<rule pattern="\b(call|cd|close|commands|data|delete|display|drop|end|environ|exit|expand|include|load|model|objective|option|problem|purge|quit|redeclare|reload|remove|reset|restore|shell|show|solexpand|solution|solve|update|unload|xref|coeff|coef|cover|obj|interval|default|from|to|to_come|net_in|net_out|dimen|dimension|check|complements|write|function|pipe|format|if|then|else|in|while|repeat|for)\b">
|
||||
<token type="KeywordReserved"/>
|
||||
</rule>
|
||||
|
||||
<!-- Types -->
|
||||
<rule pattern="\b(integer|binary|symbolic|ordered|circular|reversed|INOUT|IN|OUT|LOCAL)\b">
|
||||
<token type="KeywordType"/>
|
||||
</rule>
|
||||
|
||||
<!-- Declarations (set/param/var etc) -->
|
||||
<rule pattern="\b(set|param|var|arc|minimize|maximize|subject to|s\.t\.|subj to|node|table|suffix|read table|write table)\b">
|
||||
<token type="KeywordDeclaration"/>
|
||||
</rule>
|
||||
|
||||
<!-- Builtins / Functions -->
|
||||
<rule pattern="\b(abs|acos|acosh|alias|asin|asinh|atan|atan2|atanh|ceil|ctime|cos|exp|floor|log|log10|max|min|precision|round|sin|sinh|sqrt|tan|tanh|time|trunc|Beta|Cauchy|Exponential|Gamma|Irand224|Normal|Normal01|Poisson|Uniform|Uniform01|num|num0|ichar|char|length|substr|sprintf|match|sub|gsub|print|printf|next|nextw|prev|prevw|first|last|ord|ord0|card|arity|indexarity)\b">
|
||||
<token type="NameBuiltin"/>
|
||||
</rule>
|
||||
|
||||
<!-- Operator words (logical/set operators) -->
|
||||
<rule pattern="\b(or|exists|forall|and|in|not|within|union|diff|difference|symdiff|inter|intersect|intersection|cross|setof|by|less|sum|prod|product|div|mod)\b">
|
||||
<token type="OperatorWord"/>
|
||||
</rule>
|
||||
|
||||
<!-- Identifiers -->
|
||||
<rule pattern="[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*">
|
||||
<token type="Name"/>
|
||||
</rule>
|
||||
|
||||
<!-- Operators and punctuation -->
|
||||
<rule pattern="\+|\-|\*|/|\*\*|=|<=|>=|==|\||\^|<|>|\!|\.\.|:=|\&|\!=|<<|>>">
|
||||
<token type="Operator"/>
|
||||
</rule>
|
||||
<rule pattern="[(),;:\[\]{}.]">
|
||||
<token type="Punctuation"/>
|
||||
</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>
|
||||
</rules>
|
||||
</lexer>
|
||||
+3
-1
@@ -12,6 +12,7 @@
|
||||
<filename>*.ebuild</filename>
|
||||
<filename>*.eclass</filename>
|
||||
<filename>.env</filename>
|
||||
<filename>.env.*</filename>
|
||||
<filename>*.env</filename>
|
||||
<filename>*.exheres-0</filename>
|
||||
<filename>*.exlib</filename>
|
||||
@@ -23,6 +24,7 @@
|
||||
<filename>bash_*</filename>
|
||||
<filename>zshrc</filename>
|
||||
<filename>.zshrc</filename>
|
||||
<filename>APKBUILD</filename>
|
||||
<filename>PKGBUILD</filename>
|
||||
<mime_type>application/x-sh</mime_type>
|
||||
<mime_type>application/x-shellscript</mime_type>
|
||||
@@ -217,4 +219,4 @@
|
||||
</rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
</lexer>
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<lexer>
|
||||
<config>
|
||||
<name>ERB</name>
|
||||
<dot_all>true</dot_all>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
<rule pattern="(<%#)(.*?)(-?%>)">
|
||||
<bygroups>
|
||||
<token type="CommentPreproc"/>
|
||||
<token type="Comment"/>
|
||||
<token type="CommentPreproc"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="(<%-?=)(.*?)(-?%>)">
|
||||
<bygroups>
|
||||
<token type="CommentPreproc"/>
|
||||
<using lexer="Ruby"/>
|
||||
<token type="CommentPreproc"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="(<%-?)(.*?)(-?%>)">
|
||||
<bygroups>
|
||||
<token type="CommentPreproc"/>
|
||||
<using lexer="Ruby"/>
|
||||
<token type="CommentPreproc"/>
|
||||
</bygroups>
|
||||
</rule>
|
||||
<rule pattern="[^<]+">
|
||||
<token type="Other"/>
|
||||
</rule>
|
||||
<rule pattern="<">
|
||||
<token type="Other"/>
|
||||
</rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
+3
-3
@@ -56,7 +56,7 @@
|
||||
<token type="Text"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="([;#]|//).*?\n">
|
||||
<rule pattern="([;#@|!]|//).*\n?">
|
||||
<token type="CommentSingle"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
@@ -114,7 +114,7 @@
|
||||
<token type="Text"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="([;#]|//).*?\n">
|
||||
<rule pattern="([;#@|!]|//).*\n?">
|
||||
<token type="CommentSingle"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
@@ -139,7 +139,7 @@
|
||||
<rule pattern="\s+">
|
||||
<token type="Text"/>
|
||||
</rule>
|
||||
<rule pattern="([;#]|//).*?\n">
|
||||
<rule pattern="([;#@|!]|//).*\n?">
|
||||
<token type="CommentSingle"/>
|
||||
</rule>
|
||||
<rule pattern="/[*][\w\W]*?[*]/">
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
|
||||
<lexer>
|
||||
<config>
|
||||
<name>Gettext</name>
|
||||
<alias>pot</alias>
|
||||
<alias>po</alias>
|
||||
<filename>*.pot</filename>
|
||||
<filename>*.po</filename>
|
||||
<mime_type>application/x-gettext</mime_type>
|
||||
<mime_type>text/x-gettext</mime_type>
|
||||
<mime_type>text/gettext</mime_type>
|
||||
</config>
|
||||
<rules>
|
||||
<state name="root">
|
||||
<rule pattern="^#,\s.*?$"><token type="KeywordType"/></rule>
|
||||
<rule pattern="^#:\s.*?$"><token type="KeywordDeclaration"/></rule>
|
||||
<rule pattern="^(#|#\.\s|#\|\s|#~\s|#\s).*$"><token type="CommentSingle"/></rule>
|
||||
<rule pattern="^(")([A-Za-z-]+:)(.*")$"><bygroups><token type="LiteralString"/><token type="NameProperty"/><token type="LiteralString"/></bygroups></rule>
|
||||
<rule pattern="^".*"$"><token type="LiteralString"/></rule>
|
||||
<rule pattern="^(msgid|msgid_plural|msgstr|msgctxt)(\s+)(".*")$"><bygroups><token type="NameVariable"/><token type="Text"/><token type="LiteralString"/></bygroups></rule>
|
||||
<rule pattern="^(msgstr\[)(\d)(\])(\s+)(".*")$"><bygroups><token type="NameVariable"/><token type="LiteralNumberInteger"/><token type="NameVariable"/><token type="Text"/><token type="LiteralString"/></bygroups></rule>
|
||||
</state>
|
||||
</rules>
|
||||
</lexer>
|
||||
+4
@@ -76,6 +76,10 @@
|
||||
</rule>
|
||||
</state>
|
||||
<state name="type">
|
||||
<rule pattern="[\{\}\(\)\[\]]">
|
||||
<token type="Punctuation"/>
|
||||
<pop depth="1"/>
|
||||
</rule>
|
||||
<rule pattern="[^\W\d]\w*">
|
||||
<token type="NameClass"/>
|
||||
<pop depth="1"/>
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@
|
||||
<token type="KeywordDeclaration"/>
|
||||
<push state="slashstartsregex"/>
|
||||
</rule>
|
||||
<rule pattern="(abstract|async|await|boolean|byte|char|class|const|debugger|double|enum|export|extends|final|float|goto|implements|import|int|interface|long|native|package|private|protected|public|short|static|super|synchronized|throws|transient|volatile)\b">
|
||||
<rule pattern="(abstract|async|await|boolean|byte|char|class|const|debugger|double|enum|export|extends|final|float|goto|implements|import|int|interface|long|native|package|private|protected|public|short|static|super|synchronized|throws|transient|using|volatile)\b">
|
||||
<token type="KeywordReserved"/>
|
||||
</rule>
|
||||
<rule pattern="(true|false|null|NaN|Infinity|undefined)\b">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user