Compare commits
291
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a21044738d | ||
|
|
929c06d957 | ||
|
|
198efddb54 | ||
|
|
39408dffe8 | ||
|
|
6961dc24e5 | ||
|
|
e8b9d70061 | ||
|
|
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 | ||
|
|
307e69e5c1 | ||
|
|
d5b07d7670 | ||
|
|
41b75eba08 | ||
|
|
4aa8362de1 | ||
|
|
dc168f69bf | ||
|
|
2a4a1591fa | ||
|
|
bc612ddf81 | ||
|
|
9132a6158b | ||
|
|
658bf307b1 | ||
|
|
c6efcc56a9 | ||
|
|
bdee00b662 | ||
|
|
3013d03be2 | ||
|
|
4a2bb5eda8 | ||
|
|
d01b738741 | ||
|
|
564e8ff316 | ||
|
|
b451142790 | ||
|
|
6d8a0c783b | ||
|
|
d925584af8 | ||
|
|
22ee7a06d5 | ||
|
|
a311ff7b38 | ||
|
|
88257931ca | ||
|
|
588ec169ff | ||
|
|
d9efa765d1 | ||
|
|
baf0db1947 | ||
|
|
34ea4c3e83 | ||
|
|
8112f445f4 | ||
|
|
bd7bbe629f | ||
|
|
ed8b3c314d | ||
|
|
51ec396691 | ||
|
|
ca8ab66812 | ||
|
|
eaa64bac45 | ||
|
|
abdca907a7 | ||
|
|
a9c8cf51a0 | ||
|
|
d829a57061 | ||
|
|
1dffb2f16e | ||
|
|
9e2a1da0df | ||
|
|
6fbaa806f0 | ||
|
|
58acca4dcb | ||
|
|
22d21df22b | ||
|
|
ddb490bbc1 | ||
|
|
242499787e | ||
|
|
fd0799384f | ||
|
|
61dbf6b1f2 | ||
|
|
4a3c73db31 | ||
|
|
df510360e6 | ||
|
|
a3309516c3 | ||
|
|
5e30dc86e1 | ||
|
|
bbcba5487e | ||
|
|
c32e5efdc2 | ||
|
|
af139a7f4c | ||
|
|
89221ff2bc | ||
|
|
528242c1ca | ||
|
|
edfc5b9d76 | ||
|
|
7e944bfe0d | ||
|
|
e6afc58b34 | ||
|
|
f49b878eb4 | ||
|
|
5fe843de71 | ||
|
|
6c9ade9062 | ||
|
|
ed3c104ba4 | ||
|
|
a90b4296c6 | ||
|
|
0ebe893125 | ||
|
|
e32ca020c0 | ||
|
|
558350c546 | ||
|
|
c50a5d6245 | ||
|
|
bb650d7526 | ||
|
|
551212db82 | ||
|
|
2e00002413 | ||
|
|
81f70aa287 | ||
|
|
f104e3c775 | ||
|
|
be076d03e9 | ||
|
|
e1166a48cf | ||
|
|
3f0789fd90 | ||
|
|
518ae79556 | ||
|
|
3c55238bdd | ||
|
|
556b95a655 | ||
|
|
bfef15f05f | ||
|
|
c3ba55ba43 | ||
|
|
0af8b2eab0 | ||
|
|
82a0888c42 | ||
|
|
d5162d3fe6 | ||
|
|
e61a46efff | ||
|
|
f473105c52 | ||
|
|
c2d386230c | ||
|
|
e3ad01b555 | ||
|
|
8d9e55c33c | ||
|
|
6402ef9cae | ||
|
|
4ed48dd85c | ||
|
|
9628312b5d | ||
|
|
c65c2b395d | ||
|
|
27671c50c4 | ||
|
|
56ac1bb841 | ||
|
|
0aadf0ba86 | ||
|
|
34c667b83a | ||
|
|
f2e5758b6c | ||
|
|
cd7f616596 | ||
|
|
a0c412ee1e | ||
|
|
15f5b7dde5 | ||
|
|
a561588117 | ||
|
|
ac36dccd19 | ||
|
|
afdac27651 | ||
|
|
995c4963fb | ||
|
|
16ff2690f0 | ||
|
|
d0dc1131ea | ||
|
|
d5e3602e54 | ||
|
|
a4772bd9de | ||
|
|
d23b7003c6 | ||
|
|
3646c4a871 | ||
|
|
29fc63f116 | ||
|
|
8c59bab921 | ||
|
|
c42511d5a1 | ||
|
|
9b5f6ee2c5 | ||
|
|
b5579152cb | ||
|
|
3c78a54be6 | ||
|
|
3001b37812 | ||
|
|
63048cc798 | ||
|
|
1a27913404 | ||
|
|
4a39ceee8d | ||
|
|
1e659917d2 | ||
|
|
5c586e12bd | ||
|
|
73ca4a1b6d | ||
|
|
7604548035 | ||
|
|
7529aa7e17 | ||
|
|
c1a447b873 | ||
|
|
3c110eb0ec | ||
|
|
d7494d5392 | ||
|
|
c1048e95b3 | ||
|
|
5b6d8de807 | ||
|
|
dd2cff6a13 | ||
|
|
68fe6eb55f | ||
|
|
4d5f9f269b | ||
|
|
6140d82eb2 | ||
|
|
cff261c15b | ||
|
|
18ea40582a | ||
|
|
f9d4837567 | ||
|
|
e5ae07b1c2 | ||
|
|
4fbb3b59be | ||
|
|
9fb3014229 | ||
|
|
2a9b29dd53 | ||
|
|
872b818323 | ||
|
|
4a7bee1d98 | ||
|
|
cbe793fb42 | ||
|
|
cbf9a82a92 | ||
|
|
6cd5c45a6c | ||
|
|
0bc85a6ff6 | ||
|
|
88bf4b7d70 | ||
|
|
26c5425646 | ||
|
|
15d2ab9644 | ||
|
|
50c56cb771 | ||
|
|
e65f71b958 | ||
|
|
a9b439602b | ||
|
|
d2027f5241 | ||
|
|
95966f6407 | ||
|
|
8df986e70a | ||
|
|
71a6d0b385 | ||
|
|
974d4aaf55 | ||
|
|
dc8d7d46cb | ||
|
|
c4ace9fc0c | ||
|
|
16b07ded66 | ||
|
|
c04705947a | ||
|
|
eadd65e247 | ||
|
|
4428f79046 | ||
|
|
bad6af87f7 | ||
|
|
f7ba43e5f1 | ||
|
|
661f042cb4 | ||
|
|
877765cda2 | ||
|
|
4bb0017060 | ||
|
|
e0423b8683 | ||
|
|
5a781693aa | ||
|
|
e5743185b0 | ||
|
|
1510aec17c | ||
|
|
01837b69e5 | ||
|
|
d02099844e | ||
|
|
6fcfaa12b7 | ||
|
|
6269c19881 | ||
|
|
1313dee829 | ||
|
|
3210de8f7b | ||
|
|
532573fb34 | ||
|
|
e1173bbcad | ||
|
|
e748951182 | ||
|
|
df8670752a | ||
|
|
654250f033 | ||
|
|
0050c65596 | ||
|
|
02db7a917d | ||
|
|
81c30e01f8 | ||
|
|
82c617806d | ||
|
|
e043dc2ac3 | ||
|
|
cbd73865bd | ||
|
|
884b5685eb | ||
|
|
3f14651653 | ||
|
|
310b797679 | ||
|
|
0594565143 | ||
|
|
d4c153662b | ||
|
|
f64b0b8831 | ||
|
|
94893b77dc | ||
|
|
30f46efa5b | ||
|
|
e0904603c6 | ||
|
|
d143932924 | ||
|
|
dcc7613409 | ||
|
|
d04d2097f8 | ||
|
|
ce678bf967 | ||
|
|
cd481d401d | ||
|
|
618c267370 | ||
|
|
307866f7f5 | ||
|
|
19e1616129 | ||
|
|
c6506b8905 | ||
|
|
676ae77456 | ||
|
|
b93fc48b73 | ||
|
|
4be664e78d | ||
|
|
62aaa35287 | ||
|
|
69b18d3536 | ||
|
|
efceed6f68 | ||
|
|
f4358173c7 | ||
|
|
7a71d47556 | ||
|
|
41d27e2a7f | ||
|
|
10e4686f1a | ||
|
|
eba447319d | ||
|
|
40a0d7ca62 | ||
|
|
c9b6f9272f | ||
|
|
1f2500f16f |
@@ -0,0 +1,72 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"GitHub Actions updates":
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"Go modules updates":
|
||||
dependency-type: "production"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend/packages/api"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend/e2e"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend/apps/remark42"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site"
|
||||
open-pull-requests-limit: 0
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
"NPM modules updates":
|
||||
dependency-type: "production"
|
||||
"NPM modules updates for tests":
|
||||
dependency-type: "development"
|
||||
@@ -5,24 +5,29 @@ on:
|
||||
branches:
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-test-backend.yml"
|
||||
- ".github/workflows/ci-backend.yml"
|
||||
- "backend/**"
|
||||
- "!backend/scripts/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
paths:
|
||||
- ".github/workflows/ci-test-backend.yml"
|
||||
- ".github/workflows/ci-backend.yml"
|
||||
- "backend/**"
|
||||
- "!backend/scripts/**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: debug if needed
|
||||
run: if [[ "$DEBUG" == "true" ]]; then env; fi
|
||||
@@ -30,9 +35,10 @@ jobs:
|
||||
DEBUG: ${{secrets.DEBUG}}
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v3
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.20"
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend
|
||||
|
||||
- name: test and build backend
|
||||
run: |
|
||||
@@ -52,15 +58,15 @@ jobs:
|
||||
TZ: "America/Chicago"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
version: "v2.10.1"
|
||||
working-directory: backend/app
|
||||
|
||||
- name: golangci-lint on example directory
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
version: "v2.10.1"
|
||||
args: --config ../../.golangci.yml
|
||||
working-directory: backend/_example/memory_store
|
||||
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-build.yml"
|
||||
- "backend/**"
|
||||
- "frontend/apps/**"
|
||||
- ".dockerignore"
|
||||
- "docker-init.sh"
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
- "!frontend/packages/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-build.yml"
|
||||
@@ -23,79 +11,56 @@ on:
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-images:
|
||||
name: Build Docker images
|
||||
name: Validate Docker build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: expose GitHub Actions cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: build docker image without pushing (only outside master)
|
||||
if: ${{ github.ref != 'refs/heads/master' }}
|
||||
- name: free disk space
|
||||
run: |
|
||||
docker buildx build \
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
docker system prune -af
|
||||
|
||||
- name: build docker image without pushing
|
||||
run: |
|
||||
docker buildx build --load \
|
||||
--cache-from type=local,src=/tmp/.buildx-cache \
|
||||
--cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
||||
--platform linux/amd64 .
|
||||
|
||||
- name: build example docker image without pushing (only outside master)
|
||||
if: ${{ github.ref != 'refs/heads/master' }}
|
||||
- name: build example docker image without pushing
|
||||
run: |
|
||||
docker buildx build \
|
||||
docker buildx build --load \
|
||||
--cache-from type=local,src=/tmp/.buildx-cache \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
||||
--platform linux/amd64 -f backend/_example/memory_store/Dockerfile .
|
||||
|
||||
- name: build and deploy master image to ghcr.io and dockerhub
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
- name: rotate cache
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo "GITHUB_REF=${GITHUB_REF}, GITHUB_SHA=${GITHUB_SHA}, GIT_BRANCH=${ref}"
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
echo ${DOCKER_HUB_TOKEN} | docker login -u umputun --password-stdin
|
||||
docker buildx build --push \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github \
|
||||
--build-arg GITHUB_SHA=${GITHUB_SHA} --build-arg GIT_BRANCH=${ref} --build-arg GITHUB_REF=${GITHUB_REF} \
|
||||
--platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42:${ref} -t umputun/remark42:${ref} .
|
||||
|
||||
- name: deploy tagged (latest) to ghcr.io and dockerhub
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo "GITHUB_REF=${GITHUB_REF}, GITHUB_SHA=${GITHUB_SHA}, GIT_BRANCH=${ref}"
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
echo ${DOCKER_HUB_TOKEN} | docker login -u umputun --password-stdin
|
||||
docker buildx build --push \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github \
|
||||
--build-arg GITHUB_SHA=${GITHUB_SHA} --build-arg GIT_BRANCH=${ref} --build-arg GITHUB_REF=${GITHUB_REF} \
|
||||
--platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42:${ref} -t ghcr.io/umputun/remark42:latest \
|
||||
-t umputun/remark42:${ref} -t umputun/remark42:latest .
|
||||
|
||||
- name: remote deployment to remark42.com from master
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||
run: curl -s https://jess.umputun.com/update/remark42-core/${UPDATER_KEY}
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache || true
|
||||
|
||||
@@ -18,38 +18,30 @@ jobs:
|
||||
type-check:
|
||||
name: Type check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [ 16 ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -62,38 +54,30 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [ 16 ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -106,38 +90,30 @@ jobs:
|
||||
test:
|
||||
name: Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [ 16 ]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -147,7 +123,9 @@ jobs:
|
||||
run: pnpm coverage:api
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Submit coverage
|
||||
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
working-directory: ./frontend
|
||||
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
|
||||
|
||||
@@ -18,38 +18,30 @@ jobs:
|
||||
translations-check:
|
||||
name: Translations check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -62,38 +54,30 @@ jobs:
|
||||
type-check:
|
||||
name: Type check
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -106,38 +90,30 @@ jobs:
|
||||
lint:
|
||||
name: Eslint & Stylelint
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -151,21 +127,25 @@ jobs:
|
||||
name: Size limit
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
CI_JOB_NUMBER: 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Check bundle size
|
||||
uses: andresz1/size-limit-action@dd31dce7dcc72a041fd3e49abf0502b13fc4ce05
|
||||
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
directory: ./frontend/apps/remark42
|
||||
@@ -174,38 +154,30 @@ jobs:
|
||||
test:
|
||||
name: Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
node: [16]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
uses: pnpm/action-setup@v6.0.4
|
||||
with:
|
||||
version: 7
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.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
|
||||
@@ -215,7 +187,9 @@ jobs:
|
||||
run: pnpm coverage
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Submit coverage
|
||||
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
working-directory: ./frontend/apps/remark42
|
||||
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
|
||||
|
||||
+114
-39
@@ -1,10 +1,11 @@
|
||||
name: site
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
@@ -13,57 +14,131 @@ on:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
name: Build site image (${{ matrix.platform }})
|
||||
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
artifact: linux-amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
artifact: linux-arm64
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PKG_TOKEN }}
|
||||
|
||||
- name: build and deploy master image to ghcr.io and dockerhub
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
- name: build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ./site
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=site-${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=site-${{ matrix.platform }},mode=max
|
||||
outputs: type=image,name=ghcr.io/umputun/remark42-site,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: upload digest
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: site-digests-${{ matrix.artifact }}
|
||||
path: /tmp/digests/*
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Create site multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: download digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: site-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: verify all digests present
|
||||
run: |
|
||||
expected=2
|
||||
actual=$(find /tmp/digests -maxdepth 1 -type f | wc -l)
|
||||
if [ "$actual" -ne "$expected" ]; then
|
||||
echo "Expected $expected digests, found $actual"
|
||||
ls -la /tmp/digests
|
||||
exit 1
|
||||
fi
|
||||
echo "All $expected digests present"
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PKG_TOKEN }}
|
||||
|
||||
- name: create manifest and push
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
working-directory: ./site
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo GITHUB_REF - $ref
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
docker buildx build --push --no-cache --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42-site:${ref} .
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/umputun/remark42-site:${ref} \
|
||||
-t ghcr.io/umputun/remark42-site:latest \
|
||||
$(printf 'ghcr.io/umputun/remark42-site@sha256:%s ' *)
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/umputun/remark42-site:${ref} \
|
||||
$(printf 'ghcr.io/umputun/remark42-site@sha256:%s ' *)
|
||||
fi
|
||||
|
||||
- name: deploy tagged (latest) to ghcr.io and dockerhub
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
working-directory: ./site
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo "GITHUB_REF=$ref, GITHUB_SHA=${GITHUB_SHA}"
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
docker buildx build --push --no-cache --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42-site:${ref} -t ghcr.io/umputun/remark42-site:latest .
|
||||
deploy:
|
||||
name: Deploy site
|
||||
runs-on: ubuntu-latest
|
||||
needs: merge
|
||||
if: github.ref == 'refs/heads/master' || github.event_name == 'release'
|
||||
permissions: {} # only calls an external URL via curl, no GitHub API access needed
|
||||
|
||||
- name: remote site deployment from master
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
steps:
|
||||
- name: trigger deployment
|
||||
env:
|
||||
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||
run: curl https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
|
||||
run: curl -sf https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
name: docker
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [backend]
|
||||
types: [completed]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker image (${{ matrix.platform }})
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event != 'pull_request' &&
|
||||
(github.event.workflow_run.head_branch == 'master' ||
|
||||
startsWith(github.event.workflow_run.head_branch, 'v'))
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
artifact: linux-amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
artifact: linux-arm64
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PKG_TOKEN }}
|
||||
|
||||
- name: login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: umputun
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
docker system prune -af
|
||||
|
||||
- name: build and push to ghcr.io by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||
build-args: |
|
||||
SKIP_BACKEND_TEST=true
|
||||
SKIP_FRONTEND_TEST=true
|
||||
CI=github
|
||||
GITHUB_SHA=${{ github.event.workflow_run.head_sha }}
|
||||
GIT_BRANCH=${{ github.event.workflow_run.head_branch }}
|
||||
GITHUB_REF=refs/heads/${{ github.event.workflow_run.head_branch }}
|
||||
outputs: type=image,name=ghcr.io/umputun/remark42,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: build and push to DockerHub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
build-args: |
|
||||
SKIP_BACKEND_TEST=true
|
||||
SKIP_FRONTEND_TEST=true
|
||||
CI=github
|
||||
GITHUB_SHA=${{ github.event.workflow_run.head_sha }}
|
||||
GIT_BRANCH=${{ github.event.workflow_run.head_branch }}
|
||||
GITHUB_REF=refs/heads/${{ github.event.workflow_run.head_branch }}
|
||||
outputs: type=image,name=umputun/remark42,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: export digests
|
||||
run: |
|
||||
mkdir -p /tmp/digests/ghcr /tmp/digests/dockerhub
|
||||
digest_ghcr="${{ steps.build-ghcr.outputs.digest }}"
|
||||
digest_dockerhub="${{ steps.build-dockerhub.outputs.digest }}"
|
||||
touch "/tmp/digests/ghcr/${digest_ghcr#sha256:}"
|
||||
touch "/tmp/digests/dockerhub/${digest_dockerhub#sha256:}"
|
||||
|
||||
- name: upload ghcr digest
|
||||
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@v7
|
||||
with:
|
||||
name: digests-dockerhub-${{ matrix.artifact }}
|
||||
path: /tmp/digests/dockerhub/*
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Create multi-arch manifest
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: download ghcr digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests/ghcr
|
||||
pattern: digests-ghcr-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: download dockerhub digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: /tmp/digests/dockerhub
|
||||
pattern: digests-dockerhub-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: verify all digests present
|
||||
run: |
|
||||
expected=2
|
||||
for registry in ghcr dockerhub; do
|
||||
actual=$(find /tmp/digests/$registry -maxdepth 1 -type f | wc -l)
|
||||
if [ "$actual" -ne "$expected" ]; then
|
||||
echo "Expected $expected digests for $registry, found $actual"
|
||||
ls -la /tmp/digests/$registry
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "All digests present for both registries"
|
||||
|
||||
- name: set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: login to ghcr.io
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PKG_TOKEN }}
|
||||
|
||||
- name: login to DockerHub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: umputun
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: create ghcr.io manifest and push
|
||||
working-directory: /tmp/digests/ghcr
|
||||
env:
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
if [[ "$HEAD_BRANCH" == v* ]]; then
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/umputun/remark42:${HEAD_BRANCH} \
|
||||
-t ghcr.io/umputun/remark42:latest \
|
||||
$(printf 'ghcr.io/umputun/remark42@sha256:%s ' *)
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t ghcr.io/umputun/remark42:${HEAD_BRANCH} \
|
||||
$(printf 'ghcr.io/umputun/remark42@sha256:%s ' *)
|
||||
fi
|
||||
|
||||
- name: create DockerHub manifest and push
|
||||
working-directory: /tmp/digests/dockerhub
|
||||
env:
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
run: |
|
||||
if [[ "$HEAD_BRANCH" == v* ]]; then
|
||||
docker buildx imagetools create \
|
||||
-t umputun/remark42:${HEAD_BRANCH} \
|
||||
-t umputun/remark42:latest \
|
||||
$(printf 'umputun/remark42@sha256:%s ' *)
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t umputun/remark42:${HEAD_BRANCH} \
|
||||
$(printf 'umputun/remark42@sha256:%s ' *)
|
||||
fi
|
||||
|
||||
deploy:
|
||||
name: Deploy to remark42.com
|
||||
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
|
||||
env:
|
||||
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||
run: curl -sf https://jess.umputun.com/update/remark42-core/${UPDATER_KEY}
|
||||
@@ -4,29 +4,37 @@ on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- ".github/workflows/e2e-tests.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/e2e/**"
|
||||
- "frontend/Dockerfile.e2e"
|
||||
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- ".github/workflows/e2e-tests.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/e2e/**"
|
||||
- "frontend/Dockerfile.e2e"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build & run containers
|
||||
id: tests
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
- 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
|
||||
@@ -0,0 +1,59 @@
|
||||
# Remark42 Development Guidelines
|
||||
|
||||
## Build/Test/Lint Commands
|
||||
- **Backend**:
|
||||
- Run server: `make rundev`
|
||||
- Build: `make backend`
|
||||
- Race test: `make race_test`
|
||||
- **Backend Testing**:
|
||||
- Run all tests: `cd backend/app && go test -timeout=60s -count 1 ./...`
|
||||
- Run single test: `cd backend/app && go test -run TestName ./path/to/package`
|
||||
- **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`
|
||||
- **Frontend**:
|
||||
- Development: `cd frontend && pnpm dev:app`
|
||||
- Tests: `cd frontend && pnpm test`
|
||||
- **Lint**:
|
||||
- Backend: `cd backend && golangci-lint run`
|
||||
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
|
||||
- Frontend: `cd frontend && pnpm lint`
|
||||
- **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**: 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`
|
||||
- **Auth**: `github.com/go-pkgz/auth/v2`
|
||||
- **Logging**: `github.com/go-pkgz/lgr`
|
||||
- **Testing**: `github.com/stretchr/testify`
|
||||
- **Notifications**: `github.com/go-pkgz/notify`
|
||||
|
||||
## Repository Structure
|
||||
- Backend: Go server using BoltDB for storage
|
||||
- Frontend: Preact/Redux-based UI with iframe embedding
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:16.15.1-alpine AS frontend-deps
|
||||
FROM --platform=$BUILDPLATFORM node:16.20-alpine AS frontend-deps
|
||||
|
||||
ARG SKIP_FRONTEND_TEST
|
||||
ARG SKIP_FRONTEND_BUILD
|
||||
@@ -11,7 +11,7 @@ COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/
|
||||
RUN \
|
||||
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
|
||||
apk add --no-cache --update git && \
|
||||
npm i -g pnpm@7; \
|
||||
npm i -g pnpm@8; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
|
||||
@@ -45,7 +45,7 @@ RUN \
|
||||
echo 'Skip frontend build'; \
|
||||
fi
|
||||
|
||||
FROM umputun/baseimage:buildgo-v1.11.0 as build-backend
|
||||
FROM umputun/baseimage:buildgo-v1.17.0 AS build-backend
|
||||
|
||||
ARG CI
|
||||
ARG GITHUB_REF
|
||||
@@ -54,15 +54,15 @@ ARG GIT_BRANCH
|
||||
ARG SKIP_BACKEND_TEST
|
||||
ARG BACKEND_TEST_TIMEOUT
|
||||
|
||||
# install gcc in order to be able to go test package with -race
|
||||
RUN apk --no-cache add gcc libc-dev
|
||||
|
||||
ADD backend /build/backend
|
||||
# to embed the frontend files statically into Remark42 binary
|
||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
|
||||
RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
|
||||
WORKDIR /build/backend
|
||||
|
||||
# install gcc in order to be able to go test package with -race
|
||||
RUN apk --no-cache add gcc libc-dev
|
||||
|
||||
RUN echo go version: `go version`
|
||||
|
||||
# run tests
|
||||
@@ -81,7 +81,7 @@ RUN \
|
||||
echo "version=$version" && \
|
||||
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
|
||||
|
||||
FROM umputun/baseimage:app-v1.11.0
|
||||
FROM umputun/baseimage:app-v1.17.0
|
||||
|
||||
ARG GITHUB_SHA
|
||||
|
||||
@@ -89,7 +89,7 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
||||
org.opencontainers.image.description="Remark42 comment engine" \
|
||||
org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \
|
||||
org.opencontainers.image.source="https://github.com/umputun/remark42" \
|
||||
org.opencontainers.image.title="Remark42" \
|
||||
org.opencontainers.image.url="https://remark42.com/" \
|
||||
org.opencontainers.image.revision="${GITHUB_SHA}"
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
FROM node:16.15.1-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@7
|
||||
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.9.2 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,16 +2,16 @@ 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 --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) \
|
||||
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) \
|
||||
--build-arg CI=true --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
|
||||
|
||||
dockerx:
|
||||
@@ -21,35 +21,25 @@ 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 ./...
|
||||
|
||||
backend:
|
||||
docker-compose -f compose-dev-backend.yml build
|
||||
docker compose -f compose-dev-backend.yml build
|
||||
|
||||
frontend:
|
||||
docker-compose -f compose-dev-frontend.yml build
|
||||
docker compose -f compose-dev-frontend.yml build
|
||||
|
||||
rundev:
|
||||
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true GITHUB_REF=$(GITHUB_REF) GITHUB_SHA=$(GITHUB_SHA) CI=true \
|
||||
docker-compose -f compose-private.yml build
|
||||
docker-compose -f compose-private.yml up
|
||||
docker compose -f compose-private.yml build
|
||||
docker compose -f compose-private.yml up
|
||||
|
||||
e2e:
|
||||
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
.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, Twitter, Facebook, Microsoft, GitHub, Yandex, Patreon 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.
|
||||
|
||||
+68
-61
@@ -1,68 +1,75 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
output:
|
||||
format: tab
|
||||
skip-dirs:
|
||||
- vendor
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
check-shadowing: true
|
||||
maligned:
|
||||
suggest-new: true
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
misspell:
|
||||
locale: US
|
||||
lll:
|
||||
line-length: 140
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- performance
|
||||
- style
|
||||
- experimental
|
||||
disabled-checks:
|
||||
- wrapperFunc
|
||||
# TODO: feel free to remove these excludes and fix the code
|
||||
- hugeParam
|
||||
- rangeValCopy
|
||||
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- bodyclose
|
||||
- megacheck
|
||||
- revive
|
||||
- govet
|
||||
- unconvert
|
||||
- gas
|
||||
- gocyclo
|
||||
- copyloopvar
|
||||
- dupl
|
||||
- gochecknoinits
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gosec
|
||||
- govet
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nakedret
|
||||
- prealloc
|
||||
- revive
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- typecheck
|
||||
- ineffassign
|
||||
- stylecheck
|
||||
- gochecknoinits
|
||||
- exportloopref
|
||||
- gocritic
|
||||
- nakedret
|
||||
- gosimple
|
||||
- prealloc
|
||||
fast: false
|
||||
disable-all: true
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- text: "at least one file in a package should have a package comment"
|
||||
linters:
|
||||
- stylecheck
|
||||
- text: "package-comments: should have a package comment"
|
||||
linters:
|
||||
- revive
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- dupl
|
||||
exclude-use-default: false
|
||||
settings:
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
gosec:
|
||||
excludes:
|
||||
- G117 # false positive: struct field name matches "secret" pattern
|
||||
gocritic:
|
||||
disabled-checks:
|
||||
- wrapperFunc
|
||||
- hugeParam
|
||||
- rangeValCopy
|
||||
enabled-tags:
|
||||
- performance
|
||||
- style
|
||||
- experimental
|
||||
govet:
|
||||
enable:
|
||||
- shadow
|
||||
lll:
|
||||
line-length: 140
|
||||
misspell:
|
||||
locale: US
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: at least one file in a package should have a package comment
|
||||
- linters:
|
||||
- revive
|
||||
text: 'package-comments: should have a package comment'
|
||||
- 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
|
||||
path: _test\.go
|
||||
paths:
|
||||
- vendor
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend
|
||||
FROM umputun/baseimage:buildgo-v1.17.0 AS build-backend
|
||||
|
||||
ADD backend /build/backend
|
||||
WORKDIR /build/backend/_example/memory_store
|
||||
|
||||
RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w"
|
||||
|
||||
FROM umputun/baseimage:app-v1.9.2
|
||||
FROM umputun/baseimage:app-v1.17.0
|
||||
|
||||
ARG GITHUB_SHA
|
||||
|
||||
@@ -13,7 +13,7 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
||||
org.opencontainers.image.description="Remark42 comment engine example JRPC memory store" \
|
||||
org.opencontainers.image.documentation="https://github.com/umputun/remark42/tree/master/backend/_example/memory_store" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \
|
||||
org.opencontainers.image.source="https://github.com/umputun/remark42" \
|
||||
org.opencontainers.image.title="Remark42 JRPC example memory store" \
|
||||
org.opencontainers.image.url="https://remark42.com/" \
|
||||
org.opencontainers.image.revision="${GITHUB_SHA}"
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
|
||||
|
||||
1. `docker-compose -f compose-dev-memstore.yml build`
|
||||
1. `docker-compose -f compose-dev-memstore.yml up`
|
||||
1. `docker compose -f compose-dev-memstore.yml build`
|
||||
1. `docker compose -f compose-dev-memstore.yml up`
|
||||
|
||||
As usual, demo site will run on http://127.0.0.1:8080/web/
|
||||
|
||||
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package. In real-life usage `replace github.com/umputun/remark42/backend => ../../` should not be used.
|
||||
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package. In real-life usage `replace github.com/umputun/remark42/backend => ../../` should not be used.
|
||||
|
||||
@@ -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:
|
||||
@@ -293,17 +293,17 @@ func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailE
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if req.Update == "" { // read detail value, no update requested
|
||||
return m.getUserDetail(req)
|
||||
return m.getUserDetail(req), nil
|
||||
}
|
||||
|
||||
return m.setUserDetail(req)
|
||||
return m.setUserDetail(req), nil
|
||||
case engine.AllUserDetails:
|
||||
// list of all details returned in case request is a read request
|
||||
// (Update is not set) and does not have UserID or Detail set
|
||||
if req.Update == "" && req.UserID == "" { // read list of all details
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.listDetails(req.Locator)
|
||||
return m.listDetails(req.Locator), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported request with userdetail all")
|
||||
default:
|
||||
@@ -319,7 +319,8 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
|
||||
|
||||
switch {
|
||||
case req.UserDetail != "": // delete user detail
|
||||
return m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
|
||||
m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
|
||||
return nil
|
||||
case req.Locator.URL != "" && req.CommentID != "" && req.UserDetail == "": // delete comment
|
||||
return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
|
||||
|
||||
@@ -332,7 +333,8 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
|
||||
m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
|
||||
return nil
|
||||
|
||||
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
|
||||
if _, ok := m.posts[req.Locator.SiteID]; !ok {
|
||||
@@ -389,10 +391,7 @@ func (m *MemData) checkFlag(req engine.FlagRequest) (val bool) {
|
||||
|
||||
func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
|
||||
|
||||
status := false
|
||||
if req.Update == engine.FlagTrue {
|
||||
status = true
|
||||
}
|
||||
status := req.Update == engine.FlagTrue
|
||||
|
||||
switch req.Flag {
|
||||
|
||||
@@ -437,29 +436,29 @@ func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
|
||||
|
||||
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
|
||||
// as an only element of the slice.
|
||||
func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
|
||||
func (m *MemData) getUserDetail(req engine.UserDetailRequest) []engine.UserDetailEntry {
|
||||
if meta, ok := m.metaUsers[req.UserID]; ok {
|
||||
if meta.SiteID != req.Locator.SiteID {
|
||||
return []engine.UserDetailEntry{}, nil
|
||||
return []engine.UserDetailEntry{}
|
||||
}
|
||||
switch req.Detail {
|
||||
case engine.UserEmail:
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}
|
||||
case engine.UserTelegram:
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}, nil
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}
|
||||
}
|
||||
}
|
||||
|
||||
return []engine.UserDetailEntry{}, nil
|
||||
return []engine.UserDetailEntry{}
|
||||
}
|
||||
|
||||
// setUserDetail sets requested userDetail, returning complete updated UserDetailEntry as an onlyIps
|
||||
// element of the slice in case of success
|
||||
func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
|
||||
func (m *MemData) setUserDetail(req engine.UserDetailRequest) []engine.UserDetailEntry {
|
||||
var entry metaUser
|
||||
if meta, ok := m.metaUsers[req.UserID]; ok {
|
||||
if meta.SiteID != req.Locator.SiteID {
|
||||
return []engine.UserDetailEntry{}, nil
|
||||
return []engine.UserDetailEntry{}
|
||||
}
|
||||
entry = meta
|
||||
}
|
||||
@@ -476,42 +475,42 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
|
||||
case engine.UserEmail:
|
||||
entry.Details.Email = req.Update
|
||||
m.metaUsers[req.UserID] = entry
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}
|
||||
case engine.UserTelegram:
|
||||
entry.Details.Telegram = req.Update
|
||||
m.metaUsers[req.UserID] = entry
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}, nil
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}
|
||||
}
|
||||
|
||||
return []engine.UserDetailEntry{}, nil
|
||||
return []engine.UserDetailEntry{}
|
||||
}
|
||||
|
||||
// listDetails lists all available users details for given siteID
|
||||
func (m *MemData) listDetails(loc store.Locator) ([]engine.UserDetailEntry, error) {
|
||||
func (m *MemData) listDetails(loc store.Locator) []engine.UserDetailEntry {
|
||||
var res []engine.UserDetailEntry
|
||||
for _, u := range m.metaUsers {
|
||||
if u.SiteID == loc.SiteID {
|
||||
res = append(res, u.Details)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
return res
|
||||
}
|
||||
|
||||
// deleteUserDetail deletes requested UserDetail or whole UserDetailEntry,
|
||||
// deletion of the absent entry doesn't produce error.
|
||||
// Trying to delete user with wrong siteID doesn't to anything and doesn't produce error.
|
||||
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) error {
|
||||
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) {
|
||||
var entry metaUser
|
||||
if meta, ok := m.metaUsers[userID]; ok {
|
||||
if meta.SiteID != locator.SiteID {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
entry = meta
|
||||
}
|
||||
|
||||
if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) {
|
||||
// absent entry means that we should not do anything
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
switch userDetail {
|
||||
@@ -529,7 +528,6 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet
|
||||
}
|
||||
|
||||
m.metaUsers[userID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) {
|
||||
|
||||
@@ -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)
|
||||
@@ -286,7 +287,7 @@ func TestMemData_CountUser(t *testing.T) {
|
||||
|
||||
func TestMemData_InfoPost(t *testing.T) {
|
||||
b := prepMem(t)
|
||||
ts := func(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 0, time.Local).In(time.UTC) }
|
||||
ts := func(minute int) time.Time { return time.Date(2017, 12, 20, 15, 18, minute, 0, time.Local).In(time.UTC) }
|
||||
|
||||
// add one more for https://radio-t.com/2
|
||||
comment := store.Comment{
|
||||
@@ -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) {
|
||||
|
||||
@@ -70,6 +70,17 @@ func (m *MemImage) Load(id string) ([]byte, error) {
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// Delete image by ID
|
||||
func (m *MemImage) Delete(id string) error {
|
||||
m.mu.Lock()
|
||||
// delete key from permanent and staging storage
|
||||
delete(m.images, id)
|
||||
delete(m.insertTime, id)
|
||||
delete(m.imagesStaging, id)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commit moves image from staging to permanent
|
||||
func (m *MemImage) Commit(id string) error {
|
||||
m.mu.RLock()
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
)
|
||||
|
||||
// gopher png for test, from https://golang.org/src/image/png/example_test.go
|
||||
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
|
||||
const rawGopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
|
||||
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
|
||||
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
|
||||
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
|
||||
@@ -38,7 +38,9 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
|
||||
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
|
||||
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
|
||||
|
||||
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
|
||||
func gopherPNG() io.Reader {
|
||||
return base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawGopher))
|
||||
}
|
||||
|
||||
func TestMemImage_LoadAfterSave(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
@@ -57,7 +59,8 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, gopher, img)
|
||||
|
||||
svc.ResetCleanupTimer(id)
|
||||
err = svc.ResetCleanupTimer(id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = svc.Commit(id)
|
||||
assert.NoError(t, err)
|
||||
@@ -70,6 +73,26 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
|
||||
assert.Equal(t, gopher, img)
|
||||
}
|
||||
|
||||
func TestMemImage_LoadAfterDelete(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
gopher, err := io.ReadAll(gopherPNG())
|
||||
assert.NoError(t, err)
|
||||
|
||||
id := "test_img"
|
||||
err = svc.Save(id, gopher)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = svc.Delete(id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
img, err := svc.Load(id)
|
||||
assert.EqualError(t, err, "image test_img not found")
|
||||
assert.Empty(t, img)
|
||||
|
||||
err = svc.ResetCleanupTimer(id)
|
||||
assert.EqualError(t, err, "image test_img not found")
|
||||
}
|
||||
|
||||
func TestMemImage_CommitFail(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
err := svc.Commit("test_id")
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
args:
|
||||
- SKIP_BACKEND_TEST=true
|
||||
- SKIP_FRONTEND_TEST=true
|
||||
image: umputun/remark42:dev
|
||||
image: ghcr.io/umputun/remark42:dev
|
||||
container_name: "remark42-dev"
|
||||
hostname: "remark42-dev"
|
||||
restart: always
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
module github.com/umputun/remark42/memory_store
|
||||
|
||||
go 1.20
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-pkgz/jrpc v0.3.0
|
||||
github.com/go-pkgz/lgr v0.11.0
|
||||
github.com/jessevdk/go-flags v1.5.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/go-pkgz/jrpc v0.4.0
|
||||
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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/ajg/form v1.5.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.8.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // 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/didip/tollbooth/v7 v7.0.1 // indirect
|
||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.0.10 // indirect
|
||||
github.com/go-chi/render v1.0.3 // indirect
|
||||
github.com/go-pkgz/expirable-cache v1.0.0 // indirect
|
||||
github.com/go-pkgz/rest v1.17.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // 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
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
go.etcd.io/bbolt v1.3.7 // indirect
|
||||
golang.org/x/image v0.11.0 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/image v0.40.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -1,126 +1,136 @@
|
||||
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.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
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.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=
|
||||
github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264=
|
||||
github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw=
|
||||
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
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.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=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/didip/tollbooth/v7 v7.0.0/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
|
||||
github.com/didip/tollbooth/v7 v7.0.1 h1:TkT4sBKoQoHQFPf7blQ54iHrZiTDnr8TceU+MulVAog=
|
||||
github.com/didip/tollbooth/v7 v7.0.1/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
|
||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f h1:jtKwihcLmUC9BAhoJ9adCUqdSSZcOdH2KL7mPTUm2aw=
|
||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f/go.mod h1:q9C80dnsuVRP2dAskjnXRNWdUJqtGgwG9wNrzt0019s=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
|
||||
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
||||
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
||||
github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
|
||||
github.com/go-pkgz/expirable-cache v1.0.0 h1:ns5+1hjY8hntGv8bPaQd9Gr7Jyo+Uw5SLyII40aQdtA=
|
||||
github.com/go-pkgz/expirable-cache v1.0.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
|
||||
github.com/go-pkgz/jrpc v0.3.0 h1:Fls38KqPsHzvp0FWfivr6cGnncC+iFBodHBqvUPY+0U=
|
||||
github.com/go-pkgz/jrpc v0.3.0/go.mod h1:MFtKs75JESiSqVicsQkgN2iDFFuCd3gVT1/vKiwRi00=
|
||||
github.com/go-pkgz/lgr v0.11.0 h1:9XH5o+vj09L0sRWEswIGK1lJ6g07xVB4/Z28RV9Z+qM=
|
||||
github.com/go-pkgz/lgr v0.11.0/go.mod h1:4rdRmMSs4yGFjnUg0rSDbKx21LmFNZoH4y8OLl3qDnU=
|
||||
github.com/go-pkgz/rest v1.15.6/go.mod h1:KUWAqbDteYGS/CiXftomQsKjtEOifXsJ36Ka0skYbmk=
|
||||
github.com/go-pkgz/rest v1.17.0 h1:LoBI/lDBMuqwWhOOkc6thM9NnwJO+K9nWvCOjZ7BAgE=
|
||||
github.com/go-pkgz/rest v1.17.0/go.mod h1:HHlLOt02NJc2sgffXBF6hYVMcRo4Gz3vjg43zTzN7VM=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/go-pkgz/jrpc v0.4.0 h1:oD7xiGrzDkndkuCjeHGugQXxbggLSV7O1QmHhoc5pYY=
|
||||
github.com/go-pkgz/jrpc v0.4.0/go.mod h1:JFoY3bRjRyx4M3CbEVDFQStMB1m2gmQ7OjqFK7q3kOo=
|
||||
github.com/go-pkgz/lgr v0.12.3 h1:QDug7kRkEsuQtruT9fNF5PVT2kZUqCDPc4GmsgS3fP8=
|
||||
github.com/go-pkgz/lgr v0.12.3/go.mod h1:lpCDgVvCIxBHZp8+sGCj9MPctIzKZyZ3QdE19ddqd54=
|
||||
github.com/go-pkgz/rest v1.21.0 h1:Y/C4d/TpclJJDxqnH1RAcS6Hmox0RIReAlkwMcUWXK4=
|
||||
github.com/go-pkgz/rest v1.21.0/go.mod h1:+AHzjHazq7Z3Tk/kRWOhbbAz/YZlUV40feC1Hf4NtbE=
|
||||
github.com/go-pkgz/routegroup v1.6.0 h1:44XHZgF6JIIldRlv+zjg6SygULASmjifnfIQjwCT0e4=
|
||||
github.com/go-pkgz/routegroup v1.6.0/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
|
||||
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -35,7 +35,6 @@ func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.R
|
||||
}
|
||||
err := s.img.ResetCleanupTimer(fileID)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
|
||||
}
|
||||
|
||||
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
@@ -47,6 +46,16 @@ func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response)
|
||||
return jrpc.EncodeResponse(id, value, err)
|
||||
}
|
||||
|
||||
func (s *RPC) imgDeleteHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var fileID string
|
||||
if err := json.Unmarshal(params, &fileID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.img.Delete(fileID)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
|
||||
}
|
||||
|
||||
func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var fileID string
|
||||
if err := json.Unmarshal(params, &fileID); err != nil {
|
||||
|
||||
@@ -158,4 +158,9 @@ func TestRPC_imgInfoHndl(t *testing.T) {
|
||||
info, err = ri.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, info.FirstStagingImageTS.IsZero())
|
||||
|
||||
err = ri.Delete("test_img")
|
||||
assert.NoError(t, err)
|
||||
_, err = ri.Load("test_img")
|
||||
assert.EqualError(t, err, "image test_img not found")
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ func (s *RPC) addHandlers() {
|
||||
"save_with_id": s.imgSaveWithIDHndl,
|
||||
"reset_cleanup_timer": s.imgResetClnTimerHndl,
|
||||
"load": s.imgLoadHndl,
|
||||
"delete": s.imgDeleteHndl,
|
||||
"commit": s.imgCommitHndl,
|
||||
"cleanup": s.imgCleanupHndl,
|
||||
"info": s.imgInfoHndl,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
log "github.com/go-pkgz/lgr"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/go-pkgz/auth/avatar"
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
)
|
||||
|
||||
// AvatarCommand set of flags and command for avatar migration
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-pkgz/auth/avatar"
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jessevdk/go-flags"
|
||||
@@ -16,6 +18,10 @@ func TestBackup_Execute(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin:secret", string(auth))
|
||||
fmt.Fprint(w, "blah\nblah2\n12345678\n")
|
||||
}))
|
||||
defer ts.Close()
|
||||
@@ -34,6 +40,28 @@ func TestBackup_Execute(t *testing.T) {
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
|
||||
}
|
||||
|
||||
func TestBackup_ExecuteNoPassword(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
t.Logf("Authorization: %+v", r.Header.Get("Authorization"))
|
||||
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "admin:", string(auth))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
fmt.Fprint(w, "Unauthorized")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cmd := BackupCommand{}
|
||||
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||
p := flags.NewParser(&cmd, flags.Default)
|
||||
_, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export"})
|
||||
require.NoError(t, err)
|
||||
err = cmd.Execute(nil)
|
||||
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||
}
|
||||
|
||||
func TestBackup_ExecuteFailedStatus(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
||||
|
||||
@@ -77,7 +77,7 @@ func (cc *CleanupCommand) procSpam(comments []store.Comment) int {
|
||||
log.Printf("[WARN] can't remove comment, %v", err)
|
||||
}
|
||||
}
|
||||
comment.Text = strings.Replace(comment.Text, "\n", " ", -1)
|
||||
comment.Text = strings.ReplaceAll(comment.Text, "\n", " ")
|
||||
log.Printf("[SPAM] %+v [%.0f%%]", comment, score)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ func TestCleanup_IsSpam(t *testing.T) {
|
||||
}
|
||||
|
||||
for n, tt := range tbl {
|
||||
tt := tt
|
||||
checkName := fmt.Sprintf("check-%d-%s", n, tt.name)
|
||||
t.Run(checkName, func(t *testing.T) {
|
||||
c := store.Comment{ID: checkName, Text: tt.text, Score: tt.score}
|
||||
@@ -174,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") {
|
||||
@@ -195,7 +194,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo))
|
||||
})
|
||||
|
||||
r.HandleFunc("/api/v1/admin/comment/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/api/v1/admin/comment/{id}", func(_ http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "DELETE", r.Method)
|
||||
t.Log("delete ", r.URL.Path)
|
||||
c.lock.Lock()
|
||||
@@ -203,7 +202,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
c.lock.Unlock()
|
||||
})
|
||||
|
||||
r.HandleFunc("/api/v1/admin/title/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/api/v1/admin/title/{id}", func(_ http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "PUT", r.Method)
|
||||
t.Log("title for ", r.URL.Path)
|
||||
c.lock.Lock()
|
||||
|
||||
@@ -115,13 +115,16 @@ func responseError(resp *http.Response) error {
|
||||
if e != nil {
|
||||
body = []byte("")
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("error response %q, ensure you have set ADMIN_PASSWD and provided it to the command you're running: %s", resp.Status, body)
|
||||
}
|
||||
return fmt.Errorf("error response %q, %s", resp.Status, body)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +20,10 @@ func TestImport_Execute(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin:secret", string(auth))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||
@@ -46,6 +52,42 @@ func TestImport_Execute(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestImport_ExecuteNoPassword(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin:", string(auth))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, "Unauthorized")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cmd := ImportCommand{}
|
||||
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||
|
||||
p := flags.NewParser(&cmd, flags.Default)
|
||||
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt"})
|
||||
require.NoError(t, err)
|
||||
err = cmd.Execute(nil)
|
||||
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||
|
||||
cmd = ImportCommand{}
|
||||
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||
|
||||
p = flags.NewParser(&cmd, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt.gz"})
|
||||
require.NoError(t, err)
|
||||
err = cmd.Execute(nil)
|
||||
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||
}
|
||||
|
||||
func TestImport_ExecuteFailed(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jessevdk/go-flags"
|
||||
@@ -16,6 +19,10 @@ func TestRemap_Execute(t *testing.T) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "remark", r.URL.Query().Get("site"))
|
||||
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin:secret", string(auth))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "http://oldsite.com* https://newsite.com*\nhttp://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1", string(body))
|
||||
@@ -33,3 +40,31 @@ func TestRemap_Execute(t *testing.T) {
|
||||
err = cmd.Execute(nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRemap_ExecuteNoPassword(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "remark", r.URL.Query().Get("site"))
|
||||
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin:", string(auth))
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "http://oldsite.com* https://newsite.com*\nhttp://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1", string(body))
|
||||
|
||||
w.WriteHeader(401)
|
||||
fmt.Fprint(w, "Unauthorized")
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cmd := RemapCommand{}
|
||||
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||
|
||||
p := flags.NewParser(&cmd, flags.Default)
|
||||
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/remap_urls.txt"})
|
||||
require.NoError(t, err)
|
||||
err = cmd.Execute(nil)
|
||||
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||
}
|
||||
|
||||
+401
-146
@@ -2,38 +2,44 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1" //nolint:gosec // used only for stable ID hashing, not for security
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
"github.com/go-pkgz/lcw/eventbus"
|
||||
"github.com/go-pkgz/lcw/v2/eventbus"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"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"
|
||||
"github.com/go-pkgz/auth/avatar"
|
||||
"github.com/go-pkgz/auth/provider"
|
||||
"github.com/go-pkgz/auth/provider/sender"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
"github.com/go-pkgz/auth/v2/provider"
|
||||
"github.com/go-pkgz/auth/v2/provider/sender"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/migrator"
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
"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"
|
||||
@@ -58,34 +64,36 @@ type ServerCommand struct {
|
||||
SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"`
|
||||
ImageProxy ImageProxyGroup `group:"image-proxy" namespace:"image-proxy" env-namespace:"IMAGE_PROXY"`
|
||||
|
||||
Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","`
|
||||
AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
|
||||
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
|
||||
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
|
||||
LegacyImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"[deprecated, use image-proxy.http2https] enable image proxy"`
|
||||
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
|
||||
MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"`
|
||||
RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"`
|
||||
DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"`
|
||||
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
|
||||
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"`
|
||||
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"`
|
||||
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
||||
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
|
||||
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
|
||||
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
|
||||
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
|
||||
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
|
||||
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
|
||||
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments" env-delim:","`
|
||||
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
|
||||
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
|
||||
Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","`
|
||||
AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
|
||||
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
|
||||
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
|
||||
LegacyImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"[deprecated, use image-proxy.http2https] enable image proxy"`
|
||||
MinCommentSize int `long:"min-comment" env:"MIN_COMMENT_SIZE" default:"0" description:"min comment size"`
|
||||
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
|
||||
MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"`
|
||||
RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"`
|
||||
DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"`
|
||||
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
|
||||
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; 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"`
|
||||
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
||||
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
|
||||
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
|
||||
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
|
||||
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
|
||||
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
|
||||
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
|
||||
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors'" env-delim:","`
|
||||
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
|
||||
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
|
||||
DisableFancyTextFormatting bool `long:"disable-fancy-text-formatting" env:"DISABLE_FANCY_TEXT_FORMATTING" description:"disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc)"`
|
||||
|
||||
Auth struct {
|
||||
TTL struct {
|
||||
@@ -93,30 +101,32 @@ type ServerCommand struct {
|
||||
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
|
||||
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
|
||||
|
||||
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of cookie"`
|
||||
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:"Twitter OAuth"`
|
||||
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon 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"`
|
||||
Subject string `long:"subj" env:"SUBJ" default:"remark42 confirmation" description:"email's subject"`
|
||||
ContentType string `long:"content-type" env:"CONTENT_TYPE" default:"text/html" description:"content type"`
|
||||
Host string `long:"host" env:"HOST" description:"[deprecated, use --smtp.host] SMTP host"`
|
||||
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP password"`
|
||||
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP port"`
|
||||
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] enable TLS"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] SMTP TCP connection timeout"`
|
||||
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP port"`
|
||||
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP password"`
|
||||
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] SMTP user name"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] enable TLS"`
|
||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"[deprecated, use --smtp.timeout] SMTP TCP connection timeout"`
|
||||
MsgTemplate string `long:"template" env:"TEMPLATE" description:"[deprecated] message template file" default:"email_confirmation_login.html.tmpl"`
|
||||
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
|
||||
@@ -136,7 +146,7 @@ type ImageProxyGroup struct {
|
||||
|
||||
// AppleGroup defines options for Apple auth params
|
||||
type AppleGroup struct {
|
||||
CID string `long:"cid" env:"CID" description:"Apple client ID"`
|
||||
CID string `long:"cid" env:"CID" description:"Apple client ID (App ID or Services ID)"`
|
||||
TID string `long:"tid" env:"TID" description:"Apple service ID"`
|
||||
KID string `long:"kid" env:"KID" description:"Private key ID"`
|
||||
PrivateKeyFilePath string `long:"private-key-filepath" env:"PRIVATE_KEY_FILEPATH" description:"Private key file location" default:"/srv/var/apple.p8"`
|
||||
@@ -148,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
|
||||
@@ -217,14 +249,15 @@ type TelegramGroup struct {
|
||||
|
||||
// SMTPGroup defines options for SMTP server connection, used in auth and notify modules
|
||||
type SMTPGroup struct {
|
||||
Host string `long:"host" env:"HOST" description:"SMTP host"`
|
||||
Port int `long:"port" env:"PORT" description:"SMTP port"`
|
||||
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
||||
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
|
||||
LoginAuth bool `long:"login_auth" env:"LOGIN_AUTH" description:"enable LOGIN auth instead of PLAIN"`
|
||||
StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"`
|
||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
|
||||
Host string `long:"host" env:"HOST" description:"SMTP host"`
|
||||
Port int `long:"port" env:"PORT" description:"SMTP port"`
|
||||
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
||||
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
||||
TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
|
||||
InsecureSkipVerify bool `long:"insecure_skip_verify" env:"INSECURE_SKIP_VERIFY" description:"skip certificate verification"`
|
||||
LoginAuth bool `long:"login_auth" env:"LOGIN_AUTH" description:"enable LOGIN auth instead of PLAIN"`
|
||||
StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"`
|
||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
|
||||
}
|
||||
|
||||
// NotifyGroup defines options for notification
|
||||
@@ -250,8 +283,8 @@ type NotifyGroup struct {
|
||||
} `group:"slack" namespace:"slack" env-namespace:"SLACK"`
|
||||
Webhook struct {
|
||||
URL string `long:"url" env:"URL" description:"webhook URL for admin notifications"`
|
||||
Template string `long:"template" env:"TEMPLATE" description:"webhook authentication template" default:"{\"text\": \"{{.Text}}\"}"`
|
||||
Headers []string `long:"headers" description:"webhook authentication headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code bellow to allow , inside ""
|
||||
Template string `long:"template" env:"TEMPLATE" description:"webhook payload template (Go text/template); falls back to {\"text\": {{.Text | escapeJSONString}}} when empty"`
|
||||
Headers []string `long:"headers" description:"webhook headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code below to allow , inside ""
|
||||
Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"webhook timeout" default:"5s"`
|
||||
} `group:"webhook" namespace:"webhook" env-namespace:"WEBHOOK"`
|
||||
}
|
||||
@@ -309,6 +342,7 @@ func (s *ServerCommand) Execute(_ []string) error {
|
||||
log.Printf("[INFO] start server on port %s:%d", s.Address, s.Port)
|
||||
resetEnv(
|
||||
"SECRET",
|
||||
"AUTH_APPLE_KID",
|
||||
"AUTH_GOOGLE_CSEC",
|
||||
"AUTH_GITHUB_CSEC",
|
||||
"AUTH_FACEBOOK_CSEC",
|
||||
@@ -316,6 +350,8 @@ func (s *ServerCommand) Execute(_ []string) error {
|
||||
"AUTH_TWITTER_CSEC",
|
||||
"AUTH_YANDEX_CSEC",
|
||||
"AUTH_PATREON_CSEC",
|
||||
"AUTH_DISCORD_CSEC",
|
||||
"AUTH_CUSTOM_CSEC",
|
||||
"TELEGRAM_TOKEN",
|
||||
"SMTP_PASSWORD",
|
||||
"ADMIN_PASSWD",
|
||||
@@ -402,6 +438,12 @@ func (s *ServerCommand) HandleDeprecatedFlags() (result []DeprecatedFlag) {
|
||||
if s.Notify.Telegram.API != "https://api.telegram.org/bot" {
|
||||
result = append(result, DeprecatedFlag{Old: "notify.telegram.api", Version: "1.9"})
|
||||
}
|
||||
if s.Auth.Twitter.CID != "" {
|
||||
result = append(result, DeprecatedFlag{Old: "auth.twitter.cid", Version: "1.14"})
|
||||
}
|
||||
if s.Auth.Twitter.CSEC != "" {
|
||||
result = append(result, DeprecatedFlag{Old: "auth.twitter.csec", Version: "1.14"})
|
||||
}
|
||||
return append(result, s.findDeprecatedFlagsCollisions()...)
|
||||
}
|
||||
|
||||
@@ -459,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
|
||||
@@ -500,11 +617,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
EditDuration: s.EditDuration,
|
||||
AdminEdits: s.AdminEdit,
|
||||
AdminStore: adminStore,
|
||||
MinCommentSize: s.MinCommentSize,
|
||||
MaxCommentSize: s.MaxCommentSize,
|
||||
MaxVotes: s.MaxVotes,
|
||||
PositiveScore: s.PositiveScore,
|
||||
ImageService: imageService,
|
||||
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}),
|
||||
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
|
||||
@@ -540,7 +658,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
Cache: loadingCache,
|
||||
NativeImporter: &migrator.Native{DataStore: dataService},
|
||||
DisqusImporter: &migrator.Disqus{DataStore: dataService},
|
||||
WordPressImporter: &migrator.WordPress{DataStore: dataService},
|
||||
WordPressImporter: &migrator.WordPress{DataStore: dataService, DisableFancyTextFormatting: s.DisableFancyTextFormatting},
|
||||
CommentoImporter: &migrator.Commento{DataStore: dataService},
|
||||
NativeExporter: &migrator.Native{DataStore: dataService},
|
||||
URLMapperMaker: migrator.NewURLMapper,
|
||||
@@ -575,33 +693,35 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
}
|
||||
|
||||
srv := &api.Rest{
|
||||
Version: s.Revision,
|
||||
DataService: dataService,
|
||||
WebRoot: s.WebRoot,
|
||||
WebFS: webFS,
|
||||
RemarkURL: s.RemarkURL,
|
||||
ImageProxy: imgProxy,
|
||||
CommentFormatter: commentFormatter,
|
||||
Migrator: migr,
|
||||
ReadOnlyAge: s.ReadOnlyAge,
|
||||
SharedSecret: s.SharedSecret,
|
||||
Authenticator: authenticator,
|
||||
Cache: loadingCache,
|
||||
NotifyService: notifyService,
|
||||
TelegramService: telegramService,
|
||||
SSLConfig: sslConfig,
|
||||
UpdateLimiter: s.UpdateLimit,
|
||||
ImageService: imageService,
|
||||
EmailNotifications: contains("email", s.Notify.Users),
|
||||
TelegramNotifications: contains("telegram", s.Notify.Users) && telegramService != nil,
|
||||
EmojiEnabled: s.EnableEmoji,
|
||||
AnonVote: s.AnonymousVote && s.RestrictVoteIP,
|
||||
SimpleView: s.SimpleView,
|
||||
ProxyCORS: s.ProxyCORS,
|
||||
AllowedAncestors: s.AllowedHosts,
|
||||
SendJWTHeader: s.Auth.SendJWTHeader,
|
||||
SubscribersOnly: s.SubscribersOnly,
|
||||
DisableSignature: s.DisableSignature,
|
||||
Version: s.Revision,
|
||||
DataService: dataService,
|
||||
WebRoot: s.WebRoot,
|
||||
WebFS: webFS,
|
||||
RemarkURL: s.RemarkURL,
|
||||
ImageProxy: imgProxy,
|
||||
CommentFormatter: commentFormatter,
|
||||
Migrator: migr,
|
||||
ReadOnlyAge: s.ReadOnlyAge,
|
||||
SharedSecret: s.SharedSecret,
|
||||
Authenticator: authenticator,
|
||||
Cache: loadingCache,
|
||||
NotifyService: notifyService,
|
||||
TelegramService: telegramService,
|
||||
SSLConfig: sslConfig,
|
||||
UpdateLimiter: s.UpdateLimit,
|
||||
ImageService: imageService,
|
||||
EmailNotifications: contains("email", s.Notify.Users),
|
||||
TelegramNotifications: contains("telegram", s.Notify.Users) && telegramService != nil,
|
||||
EmojiEnabled: s.EnableEmoji,
|
||||
AnonVote: s.AnonymousVote && s.RestrictVoteIP,
|
||||
SimpleView: s.SimpleView,
|
||||
ProxyCORS: s.ProxyCORS,
|
||||
AllowedAncestors: s.AllowedHosts,
|
||||
SendJWTHeader: s.Auth.SendJWTHeader,
|
||||
SubscribersOnly: s.SubscribersOnly,
|
||||
DisableSignature: s.DisableSignature,
|
||||
DisableFancyTextFormatting: s.DisableFancyTextFormatting,
|
||||
ExternalImageProxy: s.ImageProxy.CacheExternal,
|
||||
}
|
||||
|
||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
|
||||
@@ -633,6 +753,83 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extract domains from s.AllowedHosts and second level domain from s.RemarkURL.
|
||||
// It can be and IP like http://127.0.0.1 in which case we need to use whole IP as domain
|
||||
// Beware, if s.RemarkURL is in third-level domain like https://example.co.uk, co.uk will be returned.
|
||||
func (s *ServerCommand) getAllowedDomains() []string {
|
||||
rawDomains := s.AllowedHosts
|
||||
rawDomains = append(rawDomains, s.RemarkURL)
|
||||
allowedDomains := []string{}
|
||||
for _, rawURL := range rawDomains {
|
||||
// case of 'self' AllowedHosts, which is not a valid rawURL name
|
||||
if rawURL == "self" || rawURL == "'self'" || rawURL == "\"self\"" {
|
||||
continue
|
||||
}
|
||||
// AllowedHosts usually don't have https:// prefix, so we're adding it just to make parsing below work the same way as for RemarkURL
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
rawURL = "https://" + rawURL
|
||||
}
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to parse URL %s for TitleExtract whitelist: %v", rawURL, err)
|
||||
continue
|
||||
}
|
||||
domain := parsedURL.Hostname()
|
||||
|
||||
if domain == "" || // don't add empty domain as it will allow everything to be extracted
|
||||
(len(strings.Split(domain, ".")) < 2 && // don't allow single-word domains like "com"
|
||||
domain != "localhost") { // localhost is an exceptional single-word domain which is allowed
|
||||
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.
|
||||
if rawURL == s.RemarkURL && net.ParseIP(domain) == nil && len(strings.Split(domain, ".")) > 2 {
|
||||
domain = strings.Join(strings.Split(domain, ".")[len(strings.Split(domain, "."))-2:], ".")
|
||||
}
|
||||
|
||||
allowedDomains = append(allowedDomains, domain)
|
||||
}
|
||||
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 != "" {
|
||||
@@ -822,27 +1019,28 @@ func (s *ServerCommand) makeAdminStore() (admin.Store, error) {
|
||||
|
||||
func (s *ServerCommand) makeCache() (LoadingCache, error) {
|
||||
log.Printf("[INFO] make cache, type=%s", s.Cache.Type)
|
||||
o := cache.NewOpts[[]byte]()
|
||||
switch s.Cache.Type {
|
||||
case "redis_pub_sub":
|
||||
redisPubSub, err := eventbus.NewRedisPubSub(s.Cache.RedisAddr, "remark42-cache")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache backend initialization, redis PubSub initialisation: %w", err)
|
||||
}
|
||||
backend, err := cache.NewLruCache(cache.MaxCacheSize(s.Cache.Max.Size), cache.MaxValSize(s.Cache.Max.Value),
|
||||
cache.MaxKeys(s.Cache.Max.Items), cache.EventBus(redisPubSub))
|
||||
backend, err := cache.NewLruCache(o.MaxCacheSize(s.Cache.Max.Size), o.MaxValSize(s.Cache.Max.Value),
|
||||
o.MaxKeys(s.Cache.Max.Items), o.EventBus(redisPubSub))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache backend initialization: %w", err)
|
||||
}
|
||||
return cache.NewScache(backend), nil
|
||||
return cache.NewScache[[]byte](backend), nil
|
||||
case "mem":
|
||||
backend, err := cache.NewLruCache(cache.MaxCacheSize(s.Cache.Max.Size), cache.MaxValSize(s.Cache.Max.Value),
|
||||
cache.MaxKeys(s.Cache.Max.Items))
|
||||
backend, err := cache.NewLruCache(o.MaxCacheSize(s.Cache.Max.Size), o.MaxValSize(s.Cache.Max.Value),
|
||||
o.MaxKeys(s.Cache.Max.Items))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache backend initialization: %w", err)
|
||||
}
|
||||
return cache.NewScache(backend), nil
|
||||
return cache.NewScache[[]byte](backend), nil
|
||||
case "none":
|
||||
return cache.NewScache(&cache.Nop{}), nil
|
||||
return cache.NewScache[[]byte](&cache.Nop[[]byte]{}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported cache type %s", s.Cache.Type)
|
||||
}
|
||||
@@ -857,10 +1055,9 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
if s.Auth.Apple.CID != "" && s.Auth.Apple.TID != "" && s.Auth.Apple.KID != "" {
|
||||
err := authenticator.AddAppleProvider(
|
||||
provider.AppleConfig{
|
||||
ClientID: s.Auth.Apple.CID,
|
||||
TeamID: s.Auth.Apple.TID,
|
||||
KeyID: s.Auth.Apple.KID,
|
||||
ResponseMode: "query", // default is form_post which wouldn't work here
|
||||
ClientID: s.Auth.Apple.CID,
|
||||
TeamID: s.Auth.Apple.TID,
|
||||
KeyID: s.Auth.Apple.KID,
|
||||
},
|
||||
provider.LoadApplePrivateKeyFromFile(s.Auth.Apple.PrivateKeyFilePath),
|
||||
)
|
||||
@@ -882,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 != "" {
|
||||
@@ -897,6 +1094,49 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
authenticator.AddProvider("patreon", s.Auth.Patreon.CID, s.Auth.Patreon.CSEC)
|
||||
providersCount++
|
||||
}
|
||||
if s.Auth.Discord.CID != "" && s.Auth.Discord.CSEC != "" {
|
||||
authenticator.AddProvider("discord", s.Auth.Discord.CID, s.Auth.Discord.CSEC)
|
||||
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")
|
||||
@@ -910,18 +1150,19 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||
|
||||
if s.Auth.Email.Enable {
|
||||
params := sender.EmailParams{
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
SMTPUserName: s.SMTP.Username,
|
||||
SMTPPassword: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
StartTLS: s.SMTP.StartTLS,
|
||||
LoginAuth: s.SMTP.LoginAuth,
|
||||
TLS: s.SMTP.TLS,
|
||||
Charset: "UTF-8",
|
||||
From: s.Auth.Email.From,
|
||||
Subject: s.Auth.Email.Subject,
|
||||
ContentType: s.Auth.Email.ContentType,
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
SMTPUserName: s.SMTP.Username,
|
||||
SMTPPassword: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
StartTLS: s.SMTP.StartTLS,
|
||||
LoginAuth: s.SMTP.LoginAuth,
|
||||
TLS: s.SMTP.TLS,
|
||||
InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
|
||||
Charset: "UTF-8",
|
||||
From: s.Auth.Email.From,
|
||||
Subject: s.Auth.Email.Subject,
|
||||
ContentType: s.Auth.Email.ContentType,
|
||||
}
|
||||
sndr := sender.NewEmailClient(params, log.Default())
|
||||
tmpl, err := templates.Read(s.Auth.Email.MsgTemplate)
|
||||
@@ -958,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
|
||||
@@ -1043,14 +1284,14 @@ 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},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: site,
|
||||
ExpiresAt: time.Now().Add(100 * 365 * 24 * time.Hour).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{site},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(100 * 365 * 24 * time.Hour)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
@@ -1065,16 +1306,17 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
|
||||
emailParams.AdminEmails = s.Admin.Shared.Email
|
||||
}
|
||||
smtpParams := ntf.SMTPParams{
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
TLS: s.SMTP.TLS,
|
||||
StartTLS: s.SMTP.StartTLS,
|
||||
LoginAuth: s.SMTP.LoginAuth,
|
||||
Username: s.SMTP.Username,
|
||||
Password: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
ContentType: "text/html",
|
||||
Charset: "UTF-8",
|
||||
Host: s.SMTP.Host,
|
||||
Port: s.SMTP.Port,
|
||||
TLS: s.SMTP.TLS,
|
||||
StartTLS: s.SMTP.StartTLS,
|
||||
InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
|
||||
LoginAuth: s.SMTP.LoginAuth,
|
||||
Username: s.SMTP.Username,
|
||||
Password: s.SMTP.Password,
|
||||
TimeOut: s.SMTP.TimeOut,
|
||||
ContentType: "text/html",
|
||||
Charset: "UTF-8",
|
||||
}
|
||||
emailService, err := notify.NewEmail(emailParams, smtpParams)
|
||||
if err != nil {
|
||||
@@ -1145,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)
|
||||
}),
|
||||
@@ -1152,10 +1400,16 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
||||
if c.User == nil {
|
||||
return c
|
||||
}
|
||||
c.User.SetAdmin(ds.IsAdmin(c.Audience, c.User.ID))
|
||||
c.User.SetBoolAttr("blocked", ds.IsBlocked(c.Audience, c.User.ID))
|
||||
// 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
|
||||
}
|
||||
audience := c.Audience[0]
|
||||
|
||||
c.User.SetAdmin(ds.IsAdmin(audience, c.User.ID))
|
||||
c.User.SetBoolAttr("blocked", ds.IsBlocked(audience, c.User.ID))
|
||||
var err error
|
||||
c.User.Email, err = ds.GetUserEmail(c.Audience, c.User.ID)
|
||||
c.User.Email, err = ds.GetUserEmail(audience, c.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", c.User.ID, err)
|
||||
}
|
||||
@@ -1175,7 +1429,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
||||
return c
|
||||
}),
|
||||
AdminPasswd: s.AdminPasswd,
|
||||
Validator: token.ValidatorFunc(func(token string, claims token.Claims) bool { // check on each auth call (in middleware)
|
||||
Validator: token.ValidatorFunc(func(_ string, claims token.Claims) bool { // check on each auth call (in middleware)
|
||||
if claims.User == nil {
|
||||
return false
|
||||
}
|
||||
@@ -1277,20 +1531,21 @@ func splitAtCommas(s string) []string {
|
||||
|
||||
// authRefreshCache used by authenticator to minimize repeatable token refreshes
|
||||
type authRefreshCache struct {
|
||||
cache.LoadingCache
|
||||
cache.LoadingCache[token.Claims]
|
||||
}
|
||||
|
||||
func newAuthRefreshCache() *authRefreshCache {
|
||||
expirableCache, _ := cache.NewExpirableCache(cache.TTL(5 * time.Minute))
|
||||
o := cache.NewOpts[token.Claims]()
|
||||
expirableCache, _ := cache.NewExpirableCache(o.TTL(5 * time.Minute))
|
||||
return &authRefreshCache{LoadingCache: expirableCache}
|
||||
}
|
||||
|
||||
// Get implements cache getter with key converted to string
|
||||
func (c *authRefreshCache) Get(key interface{}) (interface{}, bool) {
|
||||
return c.LoadingCache.Peek(key.(string))
|
||||
func (c *authRefreshCache) Get(key string) (token.Claims, bool) {
|
||||
return c.Peek(key)
|
||||
}
|
||||
|
||||
// Set implements cache setter with key converted to string
|
||||
func (c *authRefreshCache) Set(key, value interface{}) {
|
||||
_, _ = c.LoadingCache.Get(key.(string), func() (interface{}, error) { return value, nil })
|
||||
func (c *authRefreshCache) Set(key string, value token.Claims) {
|
||||
_, _ = c.LoadingCache.Get(key, func() (token.Claims, error) { return value, nil })
|
||||
}
|
||||
|
||||
+237
-25
@@ -15,8 +15,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/auth/token"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"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"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
@@ -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")
|
||||
@@ -79,7 +80,7 @@ func TestServerApp_DevMode(t *testing.T) {
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 10+1, len(providers), "extra auth provider")
|
||||
require.Equal(t, 11+1, len(providers), "extra auth provider")
|
||||
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
@@ -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 {
|
||||
@@ -107,7 +132,7 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 10+1, len(providers), "extra auth provider for anon")
|
||||
require.Equal(t, 11+1, len(providers), "extra auth provider for anon")
|
||||
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
|
||||
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
@@ -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)
|
||||
|
||||
@@ -250,7 +275,7 @@ func TestServerApp_WithSSL(t *testing.T) {
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
@@ -457,6 +571,8 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||
"--notify.telegram.token=abcd",
|
||||
"--notify.telegram.timeout=3m",
|
||||
"--notify.telegram.api=http://example.org",
|
||||
"--auth.twitter.cid=123",
|
||||
"--auth.twitter.csec=456",
|
||||
}
|
||||
assert.Empty(t, s.SMTP.Host)
|
||||
assert.Empty(t, s.SMTP.Port)
|
||||
@@ -482,6 +598,8 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||
{Old: "notify.telegram.token", New: "telegram.token", Version: "1.9"},
|
||||
{Old: "notify.telegram.timeout", New: "telegram.timeout", Version: "1.9"},
|
||||
{Old: "notify.telegram.api", Version: "1.9"},
|
||||
{Old: "auth.twitter.cid", Version: "1.14"},
|
||||
{Old: "auth.twitter.csec", Version: "1.14"},
|
||||
},
|
||||
deprecatedFlags)
|
||||
assert.Equal(t, "smtp.example.org", s.SMTP.Host)
|
||||
@@ -605,11 +723,11 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
tkService.TokenDuration = time.Second
|
||||
|
||||
claims := token.Claims{
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "remark",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark"},
|
||||
Issuer: "remark",
|
||||
ExpiresAt: time.Now().Add(time.Second).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "github_dev",
|
||||
@@ -624,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)
|
||||
@@ -633,13 +751,13 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post")
|
||||
|
||||
// add comment with no-aud claim
|
||||
claimsNoAud := claims
|
||||
claimsNoAud.Audience = ""
|
||||
tkNoAud, err := tkService.Token(claimsNoAud)
|
||||
// try to add comment with no-aud claim
|
||||
badClaimsNoAud := claims
|
||||
badClaimsNoAud.Audience = jwt.ClaimStrings{""}
|
||||
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)
|
||||
@@ -651,6 +769,43 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body))
|
||||
|
||||
// try to add comment with multiple auds
|
||||
badClaimsMultipleAud := claims
|
||||
badClaimsMultipleAud.Audience = jwt.ClaimStrings{"remark", "second_aud"}
|
||||
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?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)
|
||||
req.Header.Set("X-JWT", tkMultipleAuds)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user with multiple auds claim rejected, \n"+tkMultipleAuds+"\n"+string(body))
|
||||
|
||||
// try to add comment without user set
|
||||
badClaimsNoUser := claims
|
||||
badClaimsNoUser.Audience = jwt.ClaimStrings{"remark"}
|
||||
badClaimsNoUser.User = nil
|
||||
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?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)
|
||||
req.Header.Set("X-JWT", tkNoUser)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without user information rejected, \n"+tkNoUser+"\n"+string(body))
|
||||
|
||||
// block user github_dev as admin
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/github_dev?site=remark&block=1&ttl=10d", port), http.NoBody)
|
||||
@@ -665,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)
|
||||
@@ -697,7 +852,6 @@ func TestServerCommand_parseSameSite(t *testing.T) {
|
||||
|
||||
cmd := ServerCommand{}
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
assert.Equal(t, tt.res, cmd.parseSameSite(tt.inp))
|
||||
})
|
||||
@@ -726,8 +880,58 @@ func Test_splitAtCommas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getAllowedDomains(t *testing.T) {
|
||||
tbl := []struct {
|
||||
s ServerCommand
|
||||
allowedDomains []string
|
||||
}{
|
||||
// correct example, parsed and returned as allowed domain
|
||||
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "https://remark42.example.org"}}, []string{"example.org"}},
|
||||
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "http://remark42.example.org"}}, []string{"example.org"}},
|
||||
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "http://localhost"}}, []string{"localhost"}},
|
||||
// incorrect URLs, so Hostname is empty but returned list doesn't include empty string as it would allow any domain
|
||||
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "bad hostname"}}, []string{}},
|
||||
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "not_a_hostname"}}, []string{}},
|
||||
// test removal of 'self', multiple AllowedHosts. No deduplication is expected
|
||||
{ServerCommand{AllowedHosts: []string{"'self'", "example.org", "test.example.org", "remark42.com"}, CommonOpts: CommonOpts{RemarkURL: "https://example.org"}}, []string{"example.org", "test.example.org", "remark42.com", "example.org"}},
|
||||
}
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
assert.Equal(t, tt.allowedDomains, tt.s.getAllowedDomains())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -741,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()
|
||||
@@ -752,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 {
|
||||
@@ -771,7 +975,6 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--site=remark"})
|
||||
require.NoError(t, err)
|
||||
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp/remark42_test", "fs", "/tmp/remark42_test", "/tmp/remark42_test"
|
||||
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
|
||||
cmd.Store.Bolt.Timeout = 10 * time.Second
|
||||
cmd.Auth.Apple.CID, cmd.Auth.Apple.KID, cmd.Auth.Apple.TID = "cid", "kid", "tid"
|
||||
cmd.Auth.Apple.PrivateKeyFilePath = "testdata/apple.p8"
|
||||
@@ -782,6 +985,7 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
cmd.Auth.Microsoft.CSEC, cmd.Auth.Microsoft.CID = "csec", "cid"
|
||||
cmd.Auth.Twitter.CSEC, cmd.Auth.Twitter.CID = "csec", "cid"
|
||||
cmd.Auth.Patreon.CSEC, cmd.Auth.Patreon.CID = "csec", "cid"
|
||||
cmd.Auth.Discord.CSEC, cmd.Auth.Discord.CID = "csec", "cid"
|
||||
cmd.Auth.Telegram = true
|
||||
cmd.Telegram.Token = "token"
|
||||
cmd.Auth.Email.Enable = true
|
||||
@@ -802,7 +1006,10 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
cmd.RestrictedNames = []string{"umputun", "bobuk"}
|
||||
cmd.emailMsgTemplatePath = "../../templates/email_reply.html.tmpl"
|
||||
cmd.emailVerificationTemplatePath = "../../templates/email_confirmation_subscription.html.tmpl"
|
||||
|
||||
cmd = fn(cmd)
|
||||
// as is uses port, call it after fn which could set it
|
||||
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
|
||||
|
||||
app, ctx, cancel := createAppFromCmd(t, cmd)
|
||||
|
||||
@@ -826,5 +1033,10 @@ func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Cont
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// ignore is added only for GitHub Actions, can't reproduce locally
|
||||
goleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"))
|
||||
goleak.VerifyTestMain(
|
||||
m,
|
||||
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
)
|
||||
}
|
||||
|
||||
+5
-6
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -55,7 +56,8 @@ func main() {
|
||||
}
|
||||
|
||||
if _, err := p.Parse(); err != nil {
|
||||
if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp {
|
||||
var flagsErr *flags.Error
|
||||
if errors.As(err, &flagsErr) && flagsErr.Type == flags.ErrHelp {
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(1)
|
||||
@@ -90,14 +92,11 @@ 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])
|
||||
}
|
||||
|
||||
// nolint:gochecknoinits // can't avoid it in this place
|
||||
//nolint:gochecknoinits // can't avoid it in this place
|
||||
func init() {
|
||||
// catch SIGQUIT and print stack traces
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
var webhookSent int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
atomic.StoreInt32(&webhookSent, 1)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
@@ -98,9 +98,6 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
main()
|
||||
assert.Eventually(t, func() bool {
|
||||
return atomic.LoadInt32(&webhookSent) == int32(1)
|
||||
}, time.Second, 100*time.Millisecond, "webhook was not sent")
|
||||
close(finished)
|
||||
}()
|
||||
|
||||
@@ -117,18 +114,23 @@ func TestMain_WithWebhook(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// wait for webhook to be sent before shutting down
|
||||
assert.Eventually(t, func() bool {
|
||||
return atomic.LoadInt32(&webhookSent) == int32(1)
|
||||
}, time.Second, 100*time.Millisecond, "webhook was not sent")
|
||||
}
|
||||
|
||||
func TestGetDump(t *testing.T) {
|
||||
dump := getDump()
|
||||
assert.True(t, strings.Contains(dump, "goroutine"))
|
||||
assert.True(t, strings.Contains(dump, "[running]"))
|
||||
assert.True(t, strings.Contains(dump, "backend/app/main.go"))
|
||||
assert.Contains(t, dump, "goroutine")
|
||||
assert.Contains(t, dump, "[running]")
|
||||
assert.Contains(t, dump, "backend/app/main.go")
|
||||
t.Logf("\n dump: %s", dump)
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -142,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()
|
||||
@@ -157,5 +159,7 @@ func TestMain(m *testing.M) {
|
||||
m,
|
||||
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
||||
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
@@ -47,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"`
|
||||
}
|
||||
|
||||
@@ -100,6 +101,11 @@ func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
}
|
||||
}
|
||||
|
||||
usersMap["anonymous"] = store.User{
|
||||
Name: "Anonymous",
|
||||
ID: "commento_" + store.EncodeID("anonymous"),
|
||||
}
|
||||
|
||||
for _, comment := range exportedData.Comments {
|
||||
u, ok := usersMap[comment.CommenterHex]
|
||||
if !ok {
|
||||
@@ -110,16 +116,28 @@ func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
continue
|
||||
}
|
||||
|
||||
parentID := comment.ParentHex
|
||||
// comments with ParentHex == "root" are top-level comments
|
||||
if parentID == "root" {
|
||||
parentID = ""
|
||||
}
|
||||
|
||||
commentURL, e := url.JoinPath("https://", comment.Domain, comment.Path)
|
||||
if e != nil {
|
||||
log.Printf("[WARN] can't construct comment URL in commento import, %s", err.Error())
|
||||
}
|
||||
log.Printf("[ERROR] commentoURL: %s", commentURL)
|
||||
|
||||
c := store.Comment{
|
||||
ID: comment.CommentHex,
|
||||
Locator: store.Locator{
|
||||
URL: comment.Path,
|
||||
URL: commentURL,
|
||||
SiteID: siteID,
|
||||
},
|
||||
User: u,
|
||||
Text: comment.Markdown,
|
||||
Timestamp: comment.CreationDate,
|
||||
ParentID: comment.ParentHex,
|
||||
ParentID: parentID,
|
||||
Imported: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ func TestCommento_Import(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
size, err := d.Import(fh, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, size)
|
||||
assert.Equal(t, 3, size)
|
||||
|
||||
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 2, len(last), "2 comments imported")
|
||||
require.Equal(t, 3, len(last), "3 comments imported")
|
||||
|
||||
t.Log(last[0])
|
||||
|
||||
@@ -44,11 +44,24 @@ func TestCommento_Import(t *testing.T) {
|
||||
assert.Equal(t, "commento_35369aeb6ac5255de30410a0f86dc71eb9c6d0ca", c.User.ID)
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
c = last[2] // anonymous comment
|
||||
assert.Equal(t, "Example comment created by user.", c.Text)
|
||||
assert.Equal(t, "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945", c.ID)
|
||||
assert.Equal(t, "", c.ParentID)
|
||||
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://example.com/blog/post/2"}, c.Locator)
|
||||
assert.Equal(t, "Anonymous", c.User.Name)
|
||||
assert.Equal(t, "commento_0a92fab3230134cca6eadd9898325b9b2ae67998", c.User.ID)
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
posts, err := dataStore.List("test", 0, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(posts), "1 post")
|
||||
assert.Equal(t, 2, len(posts), "2 posts")
|
||||
|
||||
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
|
||||
count, err = dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/2"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
|
||||
func (*Disqus) cleanText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.Replace(text, "\n", "", -1)
|
||||
text = strings.Replace(text, "\t", "", -1)
|
||||
text = strings.ReplaceAll(text, "\n", "")
|
||||
text = strings.ReplaceAll(text, "\t", "")
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,11 +77,11 @@ func TestMigrator_ImportCommento(t *testing.T) {
|
||||
Provider: "commento",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, size)
|
||||
assert.Equal(t, 3, size)
|
||||
|
||||
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(last), "2 comments imported")
|
||||
assert.Equal(t, 3, len(last), "3 comments imported")
|
||||
}
|
||||
|
||||
func TestMigrator_ImportNative(t *testing.T) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
{
|
||||
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
|
||||
"domain": "example.com",
|
||||
"url": "https://example.com/blog/post/1",
|
||||
"url": "/blog/post/2",
|
||||
"commenterHex": "anonymous",
|
||||
"markdown": "Example comment created by user.",
|
||||
"html": "",
|
||||
@@ -18,7 +18,7 @@
|
||||
{
|
||||
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
||||
"domain": "example.com",
|
||||
"url": "https://example.com/blog/post/1",
|
||||
"url": "/blog/post/1",
|
||||
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||
"markdown": "Example 2 comment created by user.",
|
||||
"html": "",
|
||||
@@ -32,7 +32,7 @@
|
||||
{
|
||||
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
|
||||
"domain": "example.com",
|
||||
"url": "https://example.com/blog/post/1",
|
||||
"url": "/blog/post/1",
|
||||
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
|
||||
"markdown": "Great reply!",
|
||||
"html": "",
|
||||
|
||||
@@ -16,7 +16,8 @@ const wpTimeLayout = "2006-01-02 15:04:05"
|
||||
|
||||
// WordPress implements Importer from WP xml
|
||||
type WordPress struct {
|
||||
DataStore Store
|
||||
DataStore Store
|
||||
DisableFancyTextFormatting bool
|
||||
}
|
||||
|
||||
type wpItem struct {
|
||||
@@ -138,7 +139,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
|
||||
ParentID: comment.PID,
|
||||
Imported: true,
|
||||
}
|
||||
commentsCh <- commentFormatter.Format(c)
|
||||
commentsCh <- commentFormatter.Format(c, w.DisableFancyTextFormatting)
|
||||
stats.inpComments++
|
||||
if stats.inpComments%1000 == 0 {
|
||||
log.Printf("[DEBUG] processed %d comments", stats.inpComments)
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestWordPress_Import(t *testing.T) {
|
||||
|
||||
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
defer dataStore.Close()
|
||||
wp := WordPress{DataStore: &dataStore}
|
||||
wp := WordPress{DataStore: &dataStore, DisableFancyTextFormatting: false}
|
||||
size, err := wp.Import(strings.NewReader(xmlTestWP), siteID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, size)
|
||||
@@ -41,7 +41,7 @@ func TestWordPress_Import(t *testing.T) {
|
||||
assert.Equal(t, "e8b1e92bbcf5b9bb88472f9bdb82d1b8c7ed39d6", c.User.IP)
|
||||
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
|
||||
assert.Equal(t, ts, c.Timestamp)
|
||||
assert.Equal(t, c.Text, "<p>Mekkatorque was over in that tent up to the right</p>\n")
|
||||
assert.Equal(t, "<p>«Mekkatorque» was over in that tent up to the right</p>\n", c.Text)
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
posts, err := dataStore.List(siteID, 0, 0)
|
||||
@@ -54,13 +54,25 @@ func TestWordPress_Import(t *testing.T) {
|
||||
count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, count)
|
||||
|
||||
// test with DisableFancyTextFormatting
|
||||
wp = WordPress{DataStore: &dataStore, DisableFancyTextFormatting: true}
|
||||
size, err = wp.Import(strings.NewReader(xmlTestWP), siteID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, size)
|
||||
|
||||
last, err = dataStore.Last(siteID, 10, time.Time{}, adminUser)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 3, len(last), "3 comments imported")
|
||||
|
||||
assert.Equal(t, "<p>"Mekkatorque" was over in that tent up to the right</p>\n", last[0].Text)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -88,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)
|
||||
}
|
||||
@@ -247,7 +259,7 @@ var xmlTestWP = `
|
||||
<wp:comment_author_IP><![CDATA[128.243.253.117]]></wp:comment_author_IP>
|
||||
<wp:comment_date><![CDATA[2010-08-18 15:19:14]]></wp:comment_date>
|
||||
<wp:comment_date_gmt><![CDATA[2010-08-18 15:19:14]]></wp:comment_date_gmt>
|
||||
<wp:comment_content><![CDATA[Mekkatorque was over in that tent up to the right]]></wp:comment_content>
|
||||
<wp:comment_content><![CDATA["Mekkatorque" was over in that tent up to the right]]></wp:comment_content>
|
||||
<wp:comment_approved><![CDATA[1]]></wp:comment_approved>
|
||||
<wp:comment_type><![CDATA[]]></wp:comment_type>
|
||||
<wp:comment_parent>13</wp:comment_parent>
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/go-pkgz/repeater"
|
||||
"github.com/go-pkgz/repeater/v2"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/templates"
|
||||
@@ -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
|
||||
@@ -161,14 +161,14 @@ func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email stri
|
||||
return err
|
||||
}
|
||||
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.Email.Send(
|
||||
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),
|
||||
),
|
||||
@@ -196,14 +196,14 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e
|
||||
return err
|
||||
}
|
||||
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.Email.Send(
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
||||
req.Email,
|
||||
e.From,
|
||||
url.QueryEscape(e.From),
|
||||
url.QueryEscape(e.VerificationSubject),
|
||||
),
|
||||
msg,
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestEmailNew(t *testing.T) {
|
||||
assert.NotNil(t, email, "email returned")
|
||||
|
||||
assert.NotNil(t, email.msgTmpl, "e.template is set")
|
||||
assert.Equal(t, emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
|
||||
assert.Equal(t, emailParams.From, email.From, "emailParams.From unchanged after creation")
|
||||
if smtpParams.TimeOut == 0 {
|
||||
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
||||
} else {
|
||||
@@ -88,7 +88,6 @@ func Test_initTemplatesErr(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, d := range testSet {
|
||||
d := d
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
e, err := NewEmail(d.emailParams, ntf.SMTPParams{})
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -66,4 +63,15 @@ func (m *MockDest) GetVerify() []VerificationRequest {
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
|
||||
// IsClosed returns closed status safely
|
||||
func (m *MockDest) IsClosed() bool {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return m.closed
|
||||
}
|
||||
|
||||
func (m *MockDest) String() string {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed)
|
||||
}
|
||||
|
||||
+195
-188
@@ -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,248 +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()
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
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()
|
||||
|
||||
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.closed)
|
||||
assert.True(t, d2.closed)
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// pruneHTML prunes string keeping HTML closing tags.
|
||||
// maxLength applies to visible text only, not HTML tags.
|
||||
func pruneHTML(htmlText string, maxLength int) string {
|
||||
var result strings.Builder
|
||||
var endTokens []string
|
||||
visibleLen := 0
|
||||
|
||||
suffix := "..."
|
||||
suffixLen := len(suffix)
|
||||
|
||||
tokenizer := html.NewTokenizer(strings.NewReader(htmlText))
|
||||
for {
|
||||
if tokenizer.Next() == html.ErrorToken {
|
||||
return result.String()
|
||||
}
|
||||
token := tokenizer.Token()
|
||||
|
||||
switch token.Type {
|
||||
case html.CommentToken, html.DoctypeToken:
|
||||
continue
|
||||
|
||||
case html.StartTagToken:
|
||||
endTokens = append([]string{fmt.Sprintf("</%s>", token.Data)}, endTokens...)
|
||||
result.WriteString(token.String())
|
||||
|
||||
case html.EndTagToken:
|
||||
if len(endTokens) > 0 {
|
||||
endTokens = endTokens[1:]
|
||||
}
|
||||
result.WriteString(token.String())
|
||||
|
||||
case html.SelfClosingTagToken:
|
||||
result.WriteString(token.String())
|
||||
|
||||
case html.TextToken:
|
||||
text := token.String()
|
||||
if visibleLen+len(text)+suffixLen > maxLength {
|
||||
remaining := maxLength - visibleLen - suffixLen
|
||||
text = pruneStringToWord(text, remaining)
|
||||
result.WriteString(text)
|
||||
result.WriteString(suffix)
|
||||
for _, endTag := range endTokens {
|
||||
result.WriteString(endTag)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
visibleLen += len(text)
|
||||
result.WriteString(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneStringToWord prunes string to specified length respecting word boundaries
|
||||
func pruneStringToWord(text string, maxLength int) string {
|
||||
if maxLength <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(text) <= maxLength {
|
||||
return text
|
||||
}
|
||||
|
||||
// find last space at or before maxLength to cut at word boundary
|
||||
lastSpace := strings.LastIndex(text[:maxLength+1], " ")
|
||||
if lastSpace <= 0 {
|
||||
return ""
|
||||
}
|
||||
return text[:lastSpace]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPruneHTML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
html string
|
||||
maxLength int
|
||||
expected string
|
||||
}{
|
||||
{"within limit", "<p>Hello</p>", 20, "<p>Hello</p>"},
|
||||
{"exceeds limit", "<p>Hello world, this is a long text</p>", 15, "<p>Hello world,...</p>"},
|
||||
{"nested tags", "<div><p>Hello world</p><p>More text</p></div>", 20, "<div><p>Hello world</p><p>More...</p></div>"},
|
||||
{"html comment stripped", "<!-- comment --><p>Hello</p>", 20, "<p>Hello</p>"},
|
||||
{"self-closing tag", "<p>Hello<br/>World</p>", 8, "<p>Hello<br/>...</p>"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, pruneHTML(tt.html, tt.maxLength))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneStringToWord(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
maxLength int
|
||||
expected string
|
||||
}{
|
||||
{"within limit", "hello world", 15, "hello world"},
|
||||
{"cut at word boundary", "hello world and more", 11, "hello world"},
|
||||
{"zero length", "hello", 0, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, pruneStringToWord(tt.text, tt.maxLength))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
|
||||
const commentTextLengthLimit = 100
|
||||
|
||||
// TelegramParams contain settings for telegram notifications
|
||||
type TelegramParams struct {
|
||||
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||
@@ -85,10 +87,10 @@ func (t *Telegram) buildMessage(req Request) string {
|
||||
msg += fmt.Sprintf(" -> <a href=%q>%s</a>", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name))
|
||||
}
|
||||
|
||||
msg += fmt.Sprintf("\n\n%s", ntf.TelegramSupportedHTML(req.Comment.Text))
|
||||
msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), commentTextLengthLimit))
|
||||
|
||||
if req.Comment.ParentID != "" {
|
||||
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", ntf.TelegramSupportedHTML(req.parent.Text))
|
||||
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", pruneHTML(ntf.TelegramSupportedHTML(req.parent.Text), commentTextLengthLimit))
|
||||
}
|
||||
|
||||
if req.Comment.PostTitle != "" {
|
||||
|
||||
@@ -53,6 +53,15 @@ some text
|
||||
|
||||
<b>Hello</b><i><b>World</b></i>`,
|
||||
res)
|
||||
|
||||
// prune string keeping HTML closing tags
|
||||
c = store.Comment{
|
||||
Text: "<b>Lorem ipsum <i>dolor sit amet</i>, consectetur adipiscing <code>elit, sed do eiusmod tempor incididunt</code> ut labore et dolore magna aliqua.</b>",
|
||||
}
|
||||
res = tb.buildMessage(Request{Comment: c})
|
||||
assert.Equal(t, `<a href="#remark42__comment-"></a>
|
||||
|
||||
<b>Lorem ipsum <i>dolor sit amet</i>, consectetur adipiscing <code>elit, sed do eiusmod tempor incididunt</code> ut...</b>`, res)
|
||||
}
|
||||
|
||||
func TestTelegram_SendVerification(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package notify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"time"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
webhookDefaultTemplate = `{"text": "{{.Text}}"}`
|
||||
webhookDefaultTemplate = `{"text": {{.Text | escapeJSONString}}}`
|
||||
)
|
||||
|
||||
// WebhookParams contain settings for webhook notifications
|
||||
@@ -49,7 +50,7 @@ func NewWebhook(params WebhookParams) (*Webhook, error) {
|
||||
params.Template = webhookDefaultTemplate
|
||||
}
|
||||
|
||||
payloadTmpl, err := template.New("webhook").Parse(params.Template)
|
||||
payloadTmpl, err := template.New("webhook").Funcs(template.FuncMap{"escapeJSONString": escapeJSONString}).Parse(params.Template)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse webhook template: %w", err)
|
||||
}
|
||||
@@ -82,3 +83,12 @@ func (w *Webhook) SendVerification(_ context.Context, _ VerificationRequest) err
|
||||
func (w *Webhook) String() string {
|
||||
return fmt.Sprintf("%s to %s", w.Webhook.String(), w.url)
|
||||
}
|
||||
|
||||
// escapeJSONString escapes string for JSON
|
||||
func escapeJSONString(s string) (string, error) {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +37,32 @@ func TestWebhook_NewWebhook(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "unable to parse webhook template")
|
||||
}
|
||||
|
||||
// https://github.com/umputun/remark42/issues/1791
|
||||
func TestWebhook_ReceiveValidJSON(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/webhook-notify")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
t.Log("received body", string(body))
|
||||
assert.JSONEq(t, `{"text": "<p>testme</p>\n"}`, string(body))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
wh, err := NewWebhook(WebhookParams{
|
||||
URL: ts.URL + "/webhook-notify",
|
||||
Headers: []string{"Content-Type:application/json,text/plain"},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, wh)
|
||||
|
||||
f := store.NewCommentFormatter()
|
||||
c := store.Comment{Text: f.FormatText("testme", false), ParentID: "1", ID: "999"}
|
||||
|
||||
err = wh.Send(context.Background(), Request{Comment: c})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWebhook_Send(t *testing.T) {
|
||||
wh, err := NewWebhook(WebhookParams{
|
||||
URL: "bad-url",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -7,9 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/go-pkgz/auth"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
|
||||
@@ -54,8 +53,7 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope))
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, R.JSON{"id": id, "locator": locator})
|
||||
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
||||
}
|
||||
|
||||
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
|
||||
@@ -69,8 +67,7 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID})
|
||||
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID})
|
||||
}
|
||||
|
||||
// GET /user/{userid}?site=side-id - get user info for requested userid
|
||||
@@ -84,8 +81,7 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, ucomments[0].User)
|
||||
R.RenderJSON(w, ucomments[0].User)
|
||||
}
|
||||
|
||||
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
|
||||
@@ -107,13 +103,21 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.AllUserDetails); err != nil {
|
||||
// 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
|
||||
}
|
||||
|
||||
audience := claims.Audience[0]
|
||||
|
||||
if err = a.dataService.DeleteUserDetail(audience, claims.User.ID, engine.AllUserDetails); err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
|
||||
return
|
||||
}
|
||||
|
||||
if err = a.dataService.DeleteUser(claims.Audience, claims.User.ID, store.HardDelete); err != nil {
|
||||
if err = a.dataService.DeleteUser(audience, claims.User.ID, store.HardDelete); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess)
|
||||
return
|
||||
}
|
||||
@@ -126,9 +130,8 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
a.cache.Flush(cache.Flusher(claims.Audience).Scopes(claims.Audience, claims.User.ID, lastCommentsScope))
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
|
||||
a.cache.Flush(cache.Flusher(audience).Scopes(audience, claims.User.ID, lastCommentsScope))
|
||||
R.RenderJSON(w, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
|
||||
}
|
||||
|
||||
// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user
|
||||
@@ -156,7 +159,7 @@ func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
||||
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
|
||||
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
|
||||
}
|
||||
|
||||
// GET /blocked?site=siteID - list blocked users
|
||||
@@ -167,7 +170,7 @@ func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users", rest.ErrSiteNotFound)
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, users)
|
||||
R.RenderJSON(w, users)
|
||||
}
|
||||
|
||||
// PUT /readonly?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
||||
@@ -194,7 +197,7 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, locator.SiteID))
|
||||
render.JSON(w, r, R.JSON{"locator": locator, "read-only": roStatus})
|
||||
R.RenderJSON(w, R.JSON{"locator": locator, "read-only": roStatus})
|
||||
}
|
||||
|
||||
// PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title
|
||||
@@ -210,8 +213,7 @@ func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[INFO] set comment's title %s to %q", id, c.PostTitle)
|
||||
|
||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, lastCommentsScope))
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, R.JSON{"id": id, "locator": locator})
|
||||
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
||||
}
|
||||
|
||||
// PUT /verify?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
||||
@@ -225,7 +227,7 @@ func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.cache.Flush(cache.Flusher(siteID).Scopes(siteID, userID))
|
||||
render.JSON(w, r, R.JSON{"user": userID, "verified": verifyStatus})
|
||||
R.RenderJSON(w, R.JSON{"user": userID, "verified": verifyStatus})
|
||||
}
|
||||
|
||||
// PUT /pin/{id}?site=siteID&url=post-url&pin=1
|
||||
@@ -240,5 +242,5 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
|
||||
render.JSON(w, r, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
|
||||
R.RenderJSON(w, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/auth/token"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -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)
|
||||
@@ -111,7 +111,7 @@ func TestAdmin_Title(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second})
|
||||
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}, []string{"127.0.0.1"})
|
||||
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.String() == "/post1" {
|
||||
_, err := w.Write([]byte("<html><title>post1 blah 123</title><body> 2222</body></html>"))
|
||||
@@ -348,7 +348,7 @@ func TestAdmin_Block(t *testing.T) {
|
||||
assert.Equal(t, "test test #1", comments.Comments[2].Text, "comment not removed and not cleared")
|
||||
assert.False(t, comments.Comments[2].Deleted, "not deleted")
|
||||
|
||||
srv.pubRest.cache = cache.NewScache(cache.NewNopCache()) // TODO: with lru cache it won't be refreshed and invalidated for long
|
||||
srv.pubRest.cache = cache.NewScache[[]byte](cache.NewNopCache[[]byte]()) // TODO: with lru cache it won't be refreshed and invalidated for long
|
||||
// time
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
@@ -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)
|
||||
@@ -513,6 +513,7 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
|
||||
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.Error(t, err)
|
||||
|
||||
// test format "tree"
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
@@ -521,6 +522,16 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
|
||||
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
|
||||
assert.True(t, comments.Info.ReadOnly)
|
||||
t.Logf("%+v", comments)
|
||||
|
||||
// test format "plain"
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
|
||||
assert.True(t, comments.Info.ReadOnly)
|
||||
t.Logf("%+v", comments)
|
||||
}
|
||||
|
||||
func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
||||
@@ -697,17 +708,17 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
||||
|
||||
claims := token.Claims{
|
||||
SessionOnly: true,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "remark42",
|
||||
Id: "1234567",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark42"},
|
||||
ID: "1234567",
|
||||
Issuer: "remark42",
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "user1",
|
||||
Picture: "pic.image",
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
},
|
||||
@@ -766,16 +777,16 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
// try with bad auth
|
||||
claims := token.Claims{
|
||||
SessionOnly: true,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "remark42",
|
||||
Id: "provider1_1234567",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark42"},
|
||||
ID: "provider1_1234567",
|
||||
Issuer: "remark42",
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "provider1_user1",
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"delete_me": true,
|
||||
},
|
||||
},
|
||||
@@ -792,9 +803,9 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try bad user
|
||||
badClaims := claims
|
||||
badClaims.User.ID = "no-such-id"
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaims)
|
||||
badClaimsUser := claims
|
||||
badClaimsUser.User.ID = "no-such-id"
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaimsUser)
|
||||
assert.NoError(t, err)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
@@ -803,11 +814,12 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.Status)
|
||||
badClaimsUser.User.ID = "provider1_user1"
|
||||
|
||||
// try without deleteme flag
|
||||
badClaims2 := claims
|
||||
badClaims2.User.SetBoolAttr("delete_me", false)
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaims2)
|
||||
badClaimsWithoutDeleteMe := claims
|
||||
badClaimsWithoutDeleteMe.User.SetBoolAttr("delete_me", false)
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaimsWithoutDeleteMe)
|
||||
assert.NoError(t, err)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
@@ -818,7 +830,25 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.True(t, strings.Contains(string(b), "can't use provided token"))
|
||||
assert.Contains(t, string(b), "can't use provided token")
|
||||
badClaimsWithoutDeleteMe.User.SetBoolAttr("delete_me", true)
|
||||
|
||||
// try with wrong audience
|
||||
badClaimsMultipleAudience := claims
|
||||
badClaimsMultipleAudience.Audience = jwt.ClaimStrings{"remark42", "something else"}
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaimsMultipleAudience)
|
||||
assert.NoError(t, err)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Contains(t, string(b), "can't process token, claims.Audience expected to be a single element but it's not")
|
||||
badClaimsMultipleAudience.Audience = jwt.ClaimStrings{"remark42"}
|
||||
}
|
||||
|
||||
func TestAdmin_GetUserInfo(t *testing.T) {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
|
||||
@@ -58,8 +59,7 @@ func (m *Migrator) importCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go m.runImport(siteID, r.URL.Query().Get("provider"), tmpfile) // import runs in background and sets busy flag for site
|
||||
|
||||
render.Status(r, http.StatusAccepted)
|
||||
render.JSON(w, r, R.JSON{"status": "import request accepted"})
|
||||
_ = R.EncodeJSON(w, http.StatusAccepted, R.JSON{"status": "import request accepted"})
|
||||
}
|
||||
|
||||
// POST /import/form?secret=key&site=site-id&provider=disqus|remark|wordpress
|
||||
@@ -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
|
||||
@@ -93,8 +94,7 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
go m.runImport(siteID, r.URL.Query().Get("provider"), tmpfile) // import runs in background and sets busy flag for site
|
||||
|
||||
render.Status(r, http.StatusAccepted)
|
||||
render.JSON(w, r, R.JSON{"status": "import request accepted"})
|
||||
_ = R.EncodeJSON(w, http.StatusAccepted, R.JSON{"status": "import request accepted"})
|
||||
}
|
||||
|
||||
// GET /wait?site=site-id
|
||||
@@ -110,20 +110,16 @@ func (m *Migrator) waitCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeOut)
|
||||
defer cancel()
|
||||
for {
|
||||
if !m.isBusy(siteID) {
|
||||
break
|
||||
}
|
||||
for m.isBusy(siteID) {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
render.Status(r, http.StatusGatewayTimeout)
|
||||
render.JSON(w, r, R.JSON{"status": "timeout expired", "site_id": siteID})
|
||||
_ = R.EncodeJSON(w, http.StatusGatewayTimeout, R.JSON{"status": "timeout expired", "site_id": siteID})
|
||||
return
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, R.JSON{"status": "completed", "site_id": siteID})
|
||||
R.RenderJSON(w, R.JSON{"status": "completed", "site_id": siteID})
|
||||
}
|
||||
|
||||
// GET /export?site=site-id&secret=12345&?mode=file|stream
|
||||
@@ -131,24 +127,32 @@ func (m *Migrator) waitCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
var writer io.Writer = w
|
||||
if r.URL.Query().Get("mode") == "file" {
|
||||
// buffer to memory to handle errors before committing to response
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
if _, err := m.NativeExporter.Export(gzWriter, siteID); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
if err := gzWriter.Close(); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
exportFile := fmt.Sprintf("%s-%s.json.gz", siteID, time.Now().Format("20060102"))
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment;filename="+exportFile)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
gzWriter := gzip.NewWriter(w)
|
||||
defer func() {
|
||||
if e := gzWriter.Close(); e != nil {
|
||||
log.Printf("[WARN] can't close gzip writer, %s", e)
|
||||
}
|
||||
}()
|
||||
writer = gzWriter
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
if _, err := io.Copy(w, &buf); err != nil {
|
||||
log.Printf("[WARN] failed to write export response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := m.NativeExporter.Export(writer, siteID); err != nil {
|
||||
// stream mode - write directly to response
|
||||
if _, err := m.NativeExporter.Export(w, siteID); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,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)
|
||||
}
|
||||
}()
|
||||
@@ -204,8 +208,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[DEBUG] convert request completed. site=%s, comments=%d", siteID, size)
|
||||
}()
|
||||
|
||||
render.Status(r, http.StatusAccepted)
|
||||
render.JSON(w, r, R.JSON{"status": "convert request accepted"})
|
||||
_ = R.EncodeJSON(w, http.StatusAccepted, R.JSON{"status": "convert request accepted"})
|
||||
}
|
||||
|
||||
// runImport reads from tmpfile and import for given siteID and provider
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -49,6 +50,22 @@ func TestMigrator_Import(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://radio-t.com/blah1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
}
|
||||
|
||||
func TestMigrator_ImportForm(t *testing.T) {
|
||||
@@ -84,13 +101,29 @@ func TestMigrator_ImportForm(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://radio-t.com/blah1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
}
|
||||
|
||||
func TestMigrator_ImportFromWP(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
r := strings.NewReader(strings.Replace(xmlTestWP, "'", "`", -1))
|
||||
r := strings.NewReader(strings.ReplaceAll(xmlTestWP, "'", "`"))
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
@@ -108,6 +141,22 @@ func TestMigrator_ImportFromWP(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://realmenweardress.es/2010/07/do-you-rp/")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, comments.Info.Count)
|
||||
require.Equal(t, 3, len(comments.Comments))
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://realmenweardress.es/2010/07/do-you-rp/")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, comments.Info.Count)
|
||||
require.Equal(t, 2, len(comments.Comments), "2 comments with 1 reply")
|
||||
}
|
||||
|
||||
func TestMigrator_ImportFromCommento(t *testing.T) {
|
||||
@@ -115,7 +164,7 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
r := strings.NewReader(`{"version":1,"comments":[{"commentHex":"7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
||||
"domain":"example.com","url":"https://example.com/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||
"domain":"example.com","url":"/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||
"markdown":"Example content","html":"","parentHex":"root","score":0,"state":"approved","creationDate":"2021-03-17T12:09:47.722181Z",
|
||||
"direction":0,"deleted":false}],"commenters":[{"commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||
"email":"somegreatmail@gmail.com","name":"User5276","link":"https://example.com/profile/257","photo":"https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
|
||||
@@ -137,6 +186,63 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/blog/post/1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://example.com/blog/post/1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
}
|
||||
|
||||
func TestMigrator_ImportFromCommentoJSON(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
r, err := os.Open("testdata/commento.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
client := &http.Client{Timeout: 1 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
|
||||
assert.NoError(t, err)
|
||||
req.Header.Add("Content-Type", "application/json; charset=utf-8")
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "{\"status\":\"import request accepted\"}\n", string(b))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/example")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 7, comments.Info.Count)
|
||||
require.Equal(t, 7, len(comments.Comments))
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://example.com/example")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 7, comments.Info.Count)
|
||||
require.Equal(t, 5, len(comments.Comments), "five comments with two replies")
|
||||
}
|
||||
|
||||
func TestMigrator_ImportRejected(t *testing.T) {
|
||||
@@ -170,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
|
||||
@@ -197,6 +303,20 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 50, comments.Info.Count)
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 50, comments.Info.Count)
|
||||
}
|
||||
|
||||
func TestMigrator_ImportWaitExpired(t *testing.T) {
|
||||
@@ -209,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
|
||||
@@ -236,6 +356,14 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
|
||||
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
|
||||
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/example")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, comments.Info.Count)
|
||||
require.Equal(t, 0, len(comments.Comments))
|
||||
}
|
||||
|
||||
func TestMigrator_Export(t *testing.T) {
|
||||
@@ -263,6 +391,16 @@ func TestMigrator_Export(t *testing.T) {
|
||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
waitForMigrationCompletion(t, ts)
|
||||
|
||||
// export wrong site, should result in error
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=test", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
|
||||
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||
|
||||
// check file mode
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
@@ -339,6 +477,7 @@ func TestMigrator_Remap(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, comments.Info.Count)
|
||||
require.Equal(t, 2, len(comments.Comments))
|
||||
require.False(t, comments.Info.ReadOnly)
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
|
||||
@@ -347,6 +486,7 @@ func TestMigrator_Remap(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
require.True(t, comments.Info.ReadOnly)
|
||||
|
||||
// we want remap urls to another domain - www.remark42.com
|
||||
@@ -364,6 +504,16 @@ func TestMigrator_Remap(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, comments.Info.Count)
|
||||
require.Equal(t, 2, len(comments.Comments))
|
||||
require.False(t, comments.Info.ReadOnly)
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://www.remark42.com/demo/")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, comments.Info.Count)
|
||||
require.Equal(t, 2, len(comments.Comments))
|
||||
require.False(t, comments.Info.ReadOnly)
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo-another/")
|
||||
@@ -372,6 +522,16 @@ func TestMigrator_Remap(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
require.True(t, comments.Info.ReadOnly)
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://www.remark42.com/demo-another/")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, comments.Info.Count)
|
||||
require.Equal(t, 1, len(comments.Comments))
|
||||
require.True(t, comments.Info.ReadOnly)
|
||||
|
||||
// should find nothing from previous url
|
||||
@@ -381,6 +541,7 @@ func TestMigrator_Remap(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, comments.Info.Count)
|
||||
require.Equal(t, 0, len(comments.Comments))
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
@@ -388,6 +549,7 @@ func TestMigrator_Remap(t *testing.T) {
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, comments.Info.Count)
|
||||
require.Equal(t, 0, len(comments.Comments))
|
||||
}
|
||||
|
||||
func TestMigrator_RemapReject(t *testing.T) {
|
||||
|
||||
+128
-65
@@ -15,14 +15,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/didip/tollbooth/v7"
|
||||
"github.com/didip/tollbooth_chi"
|
||||
"github.com/didip/tollbooth/v8"
|
||||
"github.com/didip/tollbooth/v8/limiter"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/go-pkgz/auth"
|
||||
"github.com/go-pkgz/lcw"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/go-pkgz/rest/logger"
|
||||
@@ -59,26 +58,29 @@ type Rest struct {
|
||||
Low int
|
||||
Critical int
|
||||
}
|
||||
UpdateLimiter float64
|
||||
EmailNotifications bool
|
||||
TelegramNotifications bool
|
||||
EmojiEnabled bool
|
||||
SimpleView bool
|
||||
ProxyCORS bool
|
||||
SendJWTHeader bool
|
||||
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
|
||||
SubscribersOnly bool
|
||||
DisableSignature bool // prevent signature from being added to headers
|
||||
UpdateLimiter float64
|
||||
EmailNotifications bool
|
||||
TelegramNotifications bool
|
||||
EmojiEnabled bool
|
||||
SimpleView bool
|
||||
ProxyCORS bool
|
||||
SendJWTHeader bool
|
||||
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
|
||||
SubscribersOnly bool
|
||||
DisableSignature bool // prevent signature from being added to headers
|
||||
DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
|
||||
ExternalImageProxy bool
|
||||
|
||||
SSLConfig SSLConfig
|
||||
httpsServer *http.Server
|
||||
httpServer *http.Server
|
||||
lock sync.Mutex
|
||||
|
||||
pubRest public
|
||||
privRest private
|
||||
adminRest admin
|
||||
rssRest rss
|
||||
pubRest public
|
||||
privRest private
|
||||
adminRest admin
|
||||
rssRest rss
|
||||
openRouteLimiter float64
|
||||
}
|
||||
|
||||
// LoadingCache defines interface for caching
|
||||
@@ -89,12 +91,17 @@ type LoadingCache interface {
|
||||
}
|
||||
|
||||
const hardBodyLimit = 1024 * 64 // limit size of body
|
||||
|
||||
const openRouteLimiter = 10 // limit for open routes
|
||||
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"`
|
||||
}
|
||||
|
||||
// Run the lister and request's router, activate rest server
|
||||
@@ -192,8 +199,13 @@ func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *ht
|
||||
}
|
||||
|
||||
func (s *Rest) routes() chi.Router {
|
||||
if s.openRouteLimiter == 0 {
|
||||
// set the default open route limiter. Just a safety measure as it should be set by Run method anyway
|
||||
s.openRouteLimiter = openRouteLimiter
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default()))
|
||||
router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors))
|
||||
if !s.DisableSignature {
|
||||
router.Use(R.AppInfo("remark42", "umputun", s.Version))
|
||||
}
|
||||
@@ -215,11 +227,6 @@ func (s *Rest) routes() chi.Router {
|
||||
router.Use(corsMiddleware.Handler)
|
||||
}
|
||||
|
||||
if len(s.AllowedAncestors) > 0 {
|
||||
log.Printf("[INFO] allowed from %+v only", s.AllowedAncestors)
|
||||
router.Use(frameAncestors(s.AllowedAncestors))
|
||||
}
|
||||
|
||||
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
|
||||
logInfoWithBody := logger.New(logger.Log(log.Default()), logger.WithBody, logger.IPfn(ipFn), logger.Prefix("[INFO]")).Handler
|
||||
|
||||
@@ -227,14 +234,14 @@ func (s *Rest) routes() chi.Router {
|
||||
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Timeout(5 * time.Second))
|
||||
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(2, nil)), middleware.NoCache)
|
||||
r.Use(logInfoWithBody, rateLimiter(2), middleware.NoCache)
|
||||
r.Use(validEmailAuth()) // reject suspicious email logins
|
||||
r.Mount("/auth", authHandler)
|
||||
})
|
||||
|
||||
router.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Timeout(5 * time.Second))
|
||||
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
|
||||
r.Use(rateLimiter(100))
|
||||
r.Mount("/avatar", avatarHandler)
|
||||
})
|
||||
|
||||
@@ -242,16 +249,17 @@ 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(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
|
||||
rava.Use(rateLimiter(100))
|
||||
rava.Mount("/avatar", avatarHandler)
|
||||
})
|
||||
|
||||
// open routes
|
||||
rapi.Group(func(ropen chi.Router) {
|
||||
ropen.Use(middleware.Timeout(30 * time.Second))
|
||||
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
||||
ropen.Use(rateLimiter(s.openRouteLimiter))
|
||||
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
|
||||
ropen.Get("/config", s.configCtrl)
|
||||
ropen.Get("/find", s.pubRest.findCommentsCtrl)
|
||||
@@ -262,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)
|
||||
@@ -271,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(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
||||
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)
|
||||
})
|
||||
@@ -283,7 +296,7 @@ func (s *Rest) routes() chi.Router {
|
||||
// protected routes, require auth
|
||||
rapi.Group(func(rauth chi.Router) {
|
||||
rauth.Use(middleware.Timeout(30 * time.Second))
|
||||
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
||||
rauth.Use(rateLimiter(10))
|
||||
rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody)
|
||||
rauth.Get("/user", s.privRest.userInfoCtrl)
|
||||
rauth.Get("/userdata", s.privRest.userAllDataCtrl)
|
||||
@@ -292,7 +305,7 @@ func (s *Rest) routes() chi.Router {
|
||||
// admin routes, require auth and admin users only
|
||||
rapi.Route("/admin", func(radmin chi.Router) {
|
||||
radmin.Use(middleware.Timeout(30 * time.Second))
|
||||
radmin.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
||||
radmin.Use(rateLimiter(10))
|
||||
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
|
||||
radmin.Use(middleware.NoCache, logInfoWithBody)
|
||||
|
||||
@@ -318,7 +331,7 @@ func (s *Rest) routes() chi.Router {
|
||||
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
|
||||
rapi.Group(func(rauth chi.Router) {
|
||||
rauth.Use(middleware.Timeout(10 * time.Second))
|
||||
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
|
||||
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
|
||||
rauth.Use(middleware.NoCache, logInfoWithBody)
|
||||
|
||||
@@ -338,7 +351,7 @@ func (s *Rest) routes() chi.Router {
|
||||
// protected routes, anonymous rejected
|
||||
rapi.Group(func(rauth chi.Router) {
|
||||
rauth.Use(middleware.Timeout(10 * time.Second))
|
||||
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
|
||||
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
|
||||
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
|
||||
rauth.Post("/picture", s.privRest.savePictureCtrl)
|
||||
@@ -348,7 +361,7 @@ func (s *Rest) routes() chi.Router {
|
||||
// open routes on root level
|
||||
router.Group(func(rroot chi.Router) {
|
||||
rroot.Use(middleware.Timeout(10 * time.Second))
|
||||
rroot.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil)))
|
||||
rroot.Use(rateLimiter(50))
|
||||
rroot.Get("/robots.txt", s.pubRest.robotsCtrl)
|
||||
rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||
rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||
@@ -369,16 +382,17 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
|
||||
}
|
||||
|
||||
privGrp := private{
|
||||
dataService: s.DataService,
|
||||
cache: s.Cache,
|
||||
imageService: s.ImageService,
|
||||
commentFormatter: s.CommentFormatter,
|
||||
readOnlyAge: s.ReadOnlyAge,
|
||||
authenticator: s.Authenticator,
|
||||
notifyService: s.NotifyService,
|
||||
telegramService: s.TelegramService,
|
||||
remarkURL: s.RemarkURL,
|
||||
anonVote: s.AnonVote,
|
||||
dataService: s.DataService,
|
||||
cache: s.Cache,
|
||||
imageService: s.ImageService,
|
||||
commentFormatter: s.CommentFormatter,
|
||||
readOnlyAge: s.ReadOnlyAge,
|
||||
authenticator: s.Authenticator,
|
||||
notifyService: s.NotifyService,
|
||||
telegramService: s.TelegramService,
|
||||
remarkURL: s.RemarkURL,
|
||||
anonVote: s.AnonVote,
|
||||
disableFancyTextFormatting: s.DisableFancyTextFormatting,
|
||||
}
|
||||
|
||||
admGrp := admin{
|
||||
@@ -417,6 +431,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
Version string `json:"version"`
|
||||
EditDuration int `json:"edit_duration"`
|
||||
AdminEdit bool `json:"admin_edit"`
|
||||
MinCommentSize int `json:"min_comment_size"`
|
||||
MaxCommentSize int `json:"max_comment_size"`
|
||||
Admins []string `json:"admins"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
@@ -437,6 +452,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
Version: s.Version,
|
||||
EditDuration: int(s.DataService.EditDuration.Seconds()),
|
||||
AdminEdit: s.DataService.AdminEdits,
|
||||
MinCommentSize: s.DataService.MinCommentSize,
|
||||
MaxCommentSize: s.DataService.MaxCommentSize,
|
||||
Admins: admins,
|
||||
AdminEmail: emails,
|
||||
@@ -462,8 +478,7 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
if cnf.Admins == nil { // prevent json serialization to nil
|
||||
cnf.Admins = []string{}
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, cnf)
|
||||
R.RenderJSON(w, cnf)
|
||||
}
|
||||
|
||||
// serves static files from the webRoot directory or files embedded into the compiled binary if that directory is absent
|
||||
@@ -482,7 +497,7 @@ func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) {
|
||||
webFS = http.StripPrefix("/web", webFS)
|
||||
r.Get("/web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP)
|
||||
|
||||
r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil)),
|
||||
r.With(rateLimiter(20),
|
||||
middleware.Timeout(10*time.Second),
|
||||
cacheControl(time.Hour, version),
|
||||
).Get("/web/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -495,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)
|
||||
@@ -575,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
|
||||
}
|
||||
@@ -609,19 +626,52 @@ func cacheControl(expiration time.Duration, version string) func(http.Handler) h
|
||||
}
|
||||
}
|
||||
|
||||
// frameAncestors is a middleware setting Content-Security-Policy "frame-ancestors host1 host2 ..."
|
||||
// prevents loading of comments widgets from any other origins. In case if the list of allowed empty, ignored.
|
||||
func frameAncestors(hosts []string) func(http.Handler) http.Handler {
|
||||
return func(h http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
if len(hosts) == 0 {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
|
||||
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
|
||||
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
|
||||
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
|
||||
// images — none of those should ever execute scripts when rendered, so they get the
|
||||
// strictest policy available as defense-in-depth against future trust-boundary bugs.
|
||||
//
|
||||
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
|
||||
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
|
||||
// inline; filename="image" (framing the response as a file rather than a renderable
|
||||
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
|
||||
// braces: if a future route refactor bypasses this middleware, the image handlers still
|
||||
// emit the policy.
|
||||
func apiCSPMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// securityHeadersMiddleware sets security-related headers:
|
||||
// - Content-Security-Policy: controls which resources the browser is allowed to load
|
||||
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
|
||||
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
|
||||
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
|
||||
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
|
||||
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
|
||||
// and nothing at all on HTTPS→HTTP downgrades
|
||||
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
imgSrc := "*"
|
||||
if imageProxyEnabled {
|
||||
imgSrc = "'self'"
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(hosts, " ")+";")
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
frameAncestors := "*"
|
||||
if len(allowedAncestors) > 0 {
|
||||
frameAncestors = strings.Join(allowedAncestors, " ")
|
||||
}
|
||||
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
|
||||
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
|
||||
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,3 +765,16 @@ func parseError(err error, defaultCode int) (code int) {
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
|
||||
// tollbooth v8 requires explicit IP lookup method to be set.
|
||||
// uses RemoteAddr which is set by chi's middleware.RealIP to the real client IP
|
||||
// from X-Forwarded-For, X-Real-IP, or True-Client-IP headers.
|
||||
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
|
||||
lmt := tollbooth.NewLimiter(maxReq, nil)
|
||||
lmt.SetIPLookup(limiter.IPLookup{
|
||||
Name: "RemoteAddr",
|
||||
IndexFromRight: 0,
|
||||
})
|
||||
return tollbooth.HTTPMiddleware(lmt)
|
||||
}
|
||||
|
||||
@@ -6,21 +6,22 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1" //nolint:gosec //not used for security
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/go-pkgz/auth"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/notify"
|
||||
@@ -33,16 +34,17 @@ import (
|
||||
)
|
||||
|
||||
type private struct {
|
||||
dataService privStore
|
||||
cache LoadingCache
|
||||
readOnlyAge int
|
||||
commentFormatter *store.CommentFormatter
|
||||
imageService *image.Service
|
||||
notifyService *notify.Service
|
||||
authenticator *auth.Service
|
||||
telegramService telegramService
|
||||
remarkURL string
|
||||
anonVote bool
|
||||
dataService privStore
|
||||
cache LoadingCache
|
||||
readOnlyAge int
|
||||
commentFormatter *store.CommentFormatter
|
||||
imageService *image.Service
|
||||
notifyService *notify.Service
|
||||
authenticator *auth.Service
|
||||
telegramService telegramService
|
||||
remarkURL string
|
||||
anonVote bool
|
||||
disableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
|
||||
}
|
||||
|
||||
// telegramService is a subset of Telegram service used for setting up user telegram notifications
|
||||
@@ -75,7 +77,7 @@ func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
user := rest.MustGetUserInfo(r)
|
||||
|
||||
comment := store.Comment{}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&comment); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
@@ -87,7 +89,7 @@ func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
comment = s.commentFormatter.Format(comment)
|
||||
comment = s.commentFormatter.Format(comment, s.disableFancyTextFormatting)
|
||||
comment.Sanitize()
|
||||
|
||||
// check if images are valid, omit proxied images as they are lazy-loaded
|
||||
@@ -98,14 +100,13 @@ func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
render.HTML(w, r, comment.Text)
|
||||
rest.HTMLResponse(w, http.StatusOK, comment.Text)
|
||||
}
|
||||
|
||||
// POST /comment - adds comment, resets all immutable fields
|
||||
func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
comment := store.Comment{}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&comment); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
@@ -120,14 +121,14 @@ 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 {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
||||
return
|
||||
}
|
||||
comment = s.commentFormatter.Format(comment)
|
||||
comment = s.commentFormatter.Format(comment, s.disableFancyTextFormatting)
|
||||
|
||||
// check if images are valid, omit proxied images as they are lazy-loaded
|
||||
for _, id := range s.imageService.ExtractNonProxiedPictures(comment.Text) {
|
||||
@@ -150,7 +151,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
id, err := s.dataService.Create(comment)
|
||||
if err == service.ErrRestrictedWordsFound {
|
||||
if errors.Is(err, service.ErrRestrictedWordsFound) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
|
||||
return
|
||||
}
|
||||
@@ -174,8 +175,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
log.Printf("[DEBUG] created comment %+v", finalComment)
|
||||
|
||||
render.Status(r, http.StatusCreated)
|
||||
render.JSON(w, r, &finalComment)
|
||||
_ = R.EncodeJSON(w, http.StatusCreated, &finalComment)
|
||||
}
|
||||
|
||||
// PUT /comment/{id}?site=siteID&url=post-url - update comment
|
||||
@@ -186,7 +186,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
Delete bool
|
||||
}{}
|
||||
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &edit); err != nil {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&edit); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't read comment details from body", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
editReq := service.EditRequest{
|
||||
Text: s.commentFormatter.FormatText(edit.Text),
|
||||
Text: s.commentFormatter.FormatText(edit.Text, s.disableFancyTextFormatting),
|
||||
Orig: edit.Text,
|
||||
Summary: edit.Summary,
|
||||
Delete: edit.Delete,
|
||||
@@ -219,7 +219,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
res, err := s.dataService.EditComment(locator, id, editReq)
|
||||
if err == service.ErrRestrictedWordsFound {
|
||||
if errors.Is(err, service.ErrRestrictedWordsFound) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
||||
return
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID))
|
||||
render.JSON(w, r, res)
|
||||
R.RenderJSON(w, res)
|
||||
}
|
||||
|
||||
// GET /user?site=siteID - returns user info
|
||||
@@ -244,12 +244,12 @@ func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||
}
|
||||
if len(email) > 0 {
|
||||
if email != "" {
|
||||
user.EmailSubscription = true
|
||||
}
|
||||
}
|
||||
|
||||
render.JSON(w, r, user)
|
||||
R.RenderJSON(w, user)
|
||||
}
|
||||
|
||||
// PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment
|
||||
@@ -280,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)
|
||||
@@ -290,7 +290,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID))
|
||||
render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score})
|
||||
R.RenderJSON(w, R.JSON{"id": comment.ID, "score": comment.Score})
|
||||
}
|
||||
|
||||
// getEmailCtrl gets email address for authenticated user.
|
||||
@@ -303,7 +303,7 @@ func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||
}
|
||||
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
||||
R.RenderJSON(w, R.JSON{"user": user, "address": address})
|
||||
}
|
||||
|
||||
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
||||
@@ -320,7 +320,7 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
|
||||
Address string
|
||||
autoConfirm bool
|
||||
}{autoConfirm: true}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &subscribe); err != nil {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&subscribe); err != nil {
|
||||
if err != io.EOF {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
|
||||
return
|
||||
@@ -360,10 +360,10 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: user.ID + "::" + subscribe.Address},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: r.URL.Query().Get("site"),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{r.URL.Query().Get("site")},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
@@ -383,7 +383,7 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
|
||||
},
|
||||
)
|
||||
|
||||
render.JSON(w, r, R.JSON{"user": user, "address": subscribe.Address, "updated": false})
|
||||
R.RenderJSON(w, R.JSON{"user": user, "address": subscribe.Address, "updated": false})
|
||||
}
|
||||
|
||||
// telegramSubscribeCtrl generates and verifies telegram notification request
|
||||
@@ -411,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)
|
||||
@@ -421,7 +421,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
s.telegramService.AddToken(tkn, user.ID, siteID, expires)
|
||||
|
||||
render.JSON(w, r, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
|
||||
R.RenderJSON(w, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
|
||||
|
||||
return
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
||||
R.RenderJSON(w, R.JSON{"updated": true, "address": val})
|
||||
}
|
||||
|
||||
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
||||
@@ -455,7 +455,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
|
||||
Site string
|
||||
Token string
|
||||
}{}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &confirm); err != nil {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&confirm); err != nil {
|
||||
if err != io.EOF {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
|
||||
return
|
||||
@@ -480,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)
|
||||
@@ -511,7 +511,7 @@ func (s *private) setEmail(w http.ResponseWriter, r *http.Request, userID, siteI
|
||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
||||
R.RenderJSON(w, R.JSON{"updated": true, "address": val})
|
||||
}
|
||||
|
||||
// POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
|
||||
@@ -534,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)
|
||||
@@ -579,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)
|
||||
}
|
||||
@@ -595,7 +595,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl := template.Must(template.New("unsubscribe").Parse(tmplstr))
|
||||
msg := bytes.Buffer{}
|
||||
MustExecute(tmpl, &msg, nil)
|
||||
render.HTML(w, r, msg.String())
|
||||
rest.HTMLResponse(w, http.StatusOK, msg.String())
|
||||
}
|
||||
|
||||
// DELETE /email?site=siteID - removes user's email
|
||||
@@ -622,7 +622,7 @@ func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"deleted": true})
|
||||
R.RenderJSON(w, R.JSON{"deleted": true})
|
||||
}
|
||||
|
||||
// DELETE /telegram?site=siteID - removes user's telegram
|
||||
@@ -636,7 +636,7 @@ func (s *private) deleteTelegramCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete telegram for user", code)
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"deleted": true})
|
||||
R.RenderJSON(w, R.JSON{"deleted": true})
|
||||
}
|
||||
|
||||
// GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments
|
||||
@@ -670,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)
|
||||
@@ -702,16 +702,16 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
claims := token.Claims{
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: siteID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{siteID},
|
||||
Issuer: "remark42",
|
||||
ExpiresAt: time.Now().AddDate(0, 3, 0).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().AddDate(0, 3, 0)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
},
|
||||
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
|
||||
},
|
||||
},
|
||||
@@ -724,14 +724,18 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.remarkURL, tokenStr)
|
||||
render.JSON(w, r, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link})
|
||||
R.RenderJSON(w, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link})
|
||||
}
|
||||
|
||||
// POST /image - save image with form 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
|
||||
}
|
||||
@@ -749,7 +753,7 @@ func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.JSON(w, r, R.JSON{"id": id})
|
||||
R.RenderJSON(w, R.JSON{"id": id})
|
||||
}
|
||||
|
||||
func (s *private) isReadOnly(locator store.Locator) bool {
|
||||
@@ -773,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
|
||||
}
|
||||
|
||||
@@ -16,11 +16,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
"github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -39,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)
|
||||
@@ -50,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)
|
||||
@@ -61,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)
|
||||
@@ -72,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"])
|
||||
@@ -94,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
|
||||
@@ -102,7 +102,7 @@ func TestRest_CreateAndPreviewWithImage(t *testing.T) {
|
||||
|
||||
var pngRead bool
|
||||
// server with the test PNG image
|
||||
pngServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pngServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, e := io.Copy(w, gopherPNG())
|
||||
assert.NoError(t, e)
|
||||
pngRead = true
|
||||
@@ -110,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)
|
||||
@@ -123,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)
|
||||
@@ -176,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())
|
||||
@@ -189,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())
|
||||
@@ -202,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)
|
||||
@@ -215,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)
|
||||
@@ -235,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)
|
||||
@@ -254,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)
|
||||
@@ -273,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)
|
||||
@@ -281,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)
|
||||
@@ -295,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)
|
||||
@@ -317,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)
|
||||
@@ -334,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)
|
||||
@@ -368,6 +368,56 @@ func TestRest_CreateAndGet(t *testing.T) {
|
||||
assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, IP: ""}, comment.User, "no ip")
|
||||
}
|
||||
|
||||
func TestRest_CreateWithQuotes(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
// create comment with quotes with smartypants
|
||||
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)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
c := R.JSON{}
|
||||
err = json.Unmarshal(b, &c)
|
||||
assert.NoError(t, err)
|
||||
id := c["id"].(string)
|
||||
|
||||
// get created comment by id as non-admin
|
||||
res, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comment := store.Comment{}
|
||||
err = json.Unmarshal([]byte(res), &comment)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "<p>smartpants «quoted» text</p>\n", comment.Text)
|
||||
assert.Equal(t, "smartpants \"quoted\" text", comment.Orig)
|
||||
|
||||
// create comment with quotes without smartypants
|
||||
srv.privRest.disableFancyTextFormatting = true
|
||||
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)
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
c = R.JSON{}
|
||||
err = json.Unmarshal(b, &c)
|
||||
assert.NoError(t, err)
|
||||
id = c["id"].(string)
|
||||
|
||||
// get created comment by id as non-admin
|
||||
res, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comment = store.Comment{}
|
||||
err = json.Unmarshal([]byte(res), &comment)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "<p>no_smartpants "quoted" text</p>\n", comment.Text)
|
||||
assert.Equal(t, "no_smartpants \"quoted\" text", comment.Orig)
|
||||
}
|
||||
|
||||
func TestRest_Update(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
@@ -811,10 +861,10 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
// issue good token
|
||||
claims := token.Claims{
|
||||
Handshake: &token.Handshake{ID: "provider1_dev::good@example.com"},
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "remark42",
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute).Unix(),
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"remark42"},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||
Issuer: "remark42",
|
||||
},
|
||||
}
|
||||
@@ -832,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},
|
||||
@@ -866,7 +916,6 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
for _, x := range testData {
|
||||
x := x
|
||||
t.Run(x.description, func(t *testing.T) {
|
||||
reqBody := io.NopCloser(strings.NewReader(x.body))
|
||||
if x.body == "" {
|
||||
@@ -907,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",
|
||||
@@ -921,14 +970,14 @@ func TestRest_EmailNotification(t *testing.T) {
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
parentComment := store.Comment{}
|
||||
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
|
||||
require.NoError(t, json.Unmarshal(body, &parentComment))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 1, len(mockDestination.Get()))
|
||||
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"},
|
||||
@@ -950,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)
|
||||
@@ -989,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)
|
||||
@@ -1021,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"},
|
||||
@@ -1052,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",
|
||||
@@ -1110,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)
|
||||
@@ -1157,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",
|
||||
@@ -1171,14 +1220,14 @@ func TestRest_TelegramNotification(t *testing.T) {
|
||||
require.NoError(t, resp.Body.Close())
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||
parentComment := store.Comment{}
|
||||
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
|
||||
require.NoError(t, json.Unmarshal(body, &parentComment))
|
||||
// wait for mock notification Submit to kick off
|
||||
time.Sleep(time.Millisecond * 30)
|
||||
require.Equal(t, 1, len(mockDestination.Get()))
|
||||
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"},
|
||||
@@ -1291,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"},
|
||||
@@ -1322,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",
|
||||
@@ -1407,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)
|
||||
@@ -1487,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)
|
||||
@@ -1579,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)
|
||||
@@ -1605,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)
|
||||
@@ -1642,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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,20 @@ import (
|
||||
"bytes"
|
||||
"crypto/sha1" // nolint
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
@@ -48,8 +50,18 @@ type pubStore interface {
|
||||
Counts(siteID string, postIDs []string) ([]store.PostInfo, error)
|
||||
}
|
||||
|
||||
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec
|
||||
// find comments for given post. Returns in tree or plain formats, sorted
|
||||
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec&limit=100&offset_id={id}
|
||||
// find comments for given post. Returns in tree or plain formats, sorted.
|
||||
//
|
||||
// When `url` parameter is not set (e.g. request is for site-wide comments), does not return deleted comments.
|
||||
//
|
||||
// When `limit` is set, first {limit} comments are returned. When `offset_id` is set, comments are returned starting
|
||||
// after the comment with the given id.
|
||||
// format="tree" limits comments by top-level comments and all their replies,
|
||||
// and never returns parent comment with only part of replies.
|
||||
//
|
||||
// `count` in the response refers to total number of non-deleted comments,
|
||||
// `count_left` to amount of comments left to be returned _including deleted_.
|
||||
func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
sort := r.URL.Query().Get("sort")
|
||||
@@ -68,7 +80,24 @@ func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
since = time.Time{} // since doesn't make sense for tree
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] get comments for %+v, sort %s, format %s, since %v", locator, sort, format, since)
|
||||
limitParam := r.URL.Query().Get("limit")
|
||||
var limit int
|
||||
if limitParam != "" {
|
||||
if limit, err = strconv.Atoi(limitParam); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "bad limit value", rest.ErrCommentNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
offsetID := r.URL.Query().Get("offset_id")
|
||||
if offsetID != "" {
|
||||
if _, err = uuid.Parse(offsetID); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "bad offset_id value", rest.ErrCommentNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] get comments for %+v, sort %s, format %s, since %v, limit %d, offset %s", locator, sort, format, since, limit, offsetID)
|
||||
|
||||
key := cache.NewKey(locator.SiteID).ID(URLKeyWithUser(r)).Scopes(locator.SiteID, locator.URL)
|
||||
data, err := s.cache.Get(key, func() ([]byte, error) {
|
||||
@@ -77,22 +106,44 @@ func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
comments = []store.Comment{} // error should clear comments and continue for post info
|
||||
}
|
||||
comments = s.applyView(comments, view)
|
||||
|
||||
var commentsInfo store.PostInfo
|
||||
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
|
||||
commentsInfo = info
|
||||
}
|
||||
|
||||
if !since.IsZero() { // if since is set, number of comments can be different from total in the DB
|
||||
commentsInfo.Count = 0
|
||||
for _, c := range comments {
|
||||
if !c.Deleted {
|
||||
commentsInfo.Count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// post might be readonly without any comments, Info call will fail then and ReadOnly flag should be checked separately
|
||||
if !commentsInfo.ReadOnly && locator.URL != "" && s.dataService.IsReadOnly(locator) {
|
||||
commentsInfo.ReadOnly = true
|
||||
}
|
||||
|
||||
var b []byte
|
||||
switch format {
|
||||
case "tree":
|
||||
tree := service.MakeTree(comments, sort, s.readOnlyAge)
|
||||
if tree.Nodes == nil { // eliminate json nil serialization
|
||||
tree.Nodes = []*service.Node{}
|
||||
withInfo := treeWithInfo{Tree: service.MakeTree(comments, sort, limit, offsetID), Info: commentsInfo}
|
||||
withInfo.Info.CountLeft = withInfo.CountLeft()
|
||||
withInfo.Info.LastComment = withInfo.LastComment()
|
||||
if withInfo.Nodes == nil { // eliminate json nil serialization
|
||||
withInfo.Nodes = []*service.Node{}
|
||||
}
|
||||
if s.dataService.IsReadOnly(locator) {
|
||||
tree.Info.ReadOnly = true
|
||||
}
|
||||
b, e = encodeJSONWithHTML(tree)
|
||||
b, e = encodeJSONWithHTML(withInfo)
|
||||
default:
|
||||
withInfo := commentsWithInfo{Comments: comments}
|
||||
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
|
||||
withInfo.Info = info
|
||||
if limit > 0 || offsetID != "" {
|
||||
comments, commentsInfo.CountLeft = limitComments(comments, limit, offsetID)
|
||||
}
|
||||
if limit > 0 && len(comments) > 0 {
|
||||
commentsInfo.LastComment = comments[len(comments)-1].ID
|
||||
}
|
||||
withInfo := commentsWithInfo{Comments: comments, Info: commentsInfo}
|
||||
b, e = encodeJSONWithHTML(withInfo)
|
||||
}
|
||||
return b, e
|
||||
@@ -182,7 +233,6 @@ func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id", rest.ErrCommentNotFound)
|
||||
return
|
||||
}
|
||||
render.Status(r, http.StatusOK)
|
||||
|
||||
if err = R.RenderJSONWithHTML(w, r, comment); err != nil {
|
||||
log.Printf("[WARN] can't render last comments for url=%s, id=%s", url, id)
|
||||
@@ -247,7 +297,7 @@ func (s *public) countCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get count", rest.ErrPostNotFound)
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, R.JSON{"count": count, "locator": locator})
|
||||
R.RenderJSON(w, R.JSON{"count": count, "locator": locator})
|
||||
}
|
||||
|
||||
// POST /counts?site=siteID - get number of comments for posts from post body
|
||||
@@ -255,7 +305,7 @@ func (s *public) countMultiCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
const countBodyLimit int64 = 1024 * 128 // count request can be big for some site because it lists all urls
|
||||
siteID := r.URL.Query().Get("site")
|
||||
posts := []string{}
|
||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, countBodyLimit), &posts); err != nil {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, countBodyLimit)).Decode(&posts); err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get list of posts from request", rest.ErrSiteNotFound)
|
||||
return
|
||||
}
|
||||
@@ -315,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 {
|
||||
@@ -343,13 +448,14 @@ func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// GET /robots.txt
|
||||
func (s *public) robotsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *public) robotsCtrl(w http.ResponseWriter, _ *http.Request) {
|
||||
allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/user",
|
||||
"/img", "/avatar", "/picture"}
|
||||
for i := range allowed {
|
||||
allowed[i] = "Allow: /api/v1" + allowed[i]
|
||||
}
|
||||
render.PlainText(w, r, "User-agent: *\nDisallow: /auth/\nDisallow: /api/\n"+strings.Join(allowed, "\n")+"\n")
|
||||
responseText := fmt.Sprintf("User-agent: *\nDisallow: /auth/\nDisallow: /api/\n%s\n", strings.Join(allowed, "\n"))
|
||||
rest.PlainTextResponse(w, http.StatusOK, responseText)
|
||||
}
|
||||
|
||||
// GET /qr/telegram - generates QR for provided URL, used for Telegram auth and notifications subscription. The first
|
||||
@@ -381,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)
|
||||
}
|
||||
}
|
||||
@@ -416,3 +522,25 @@ func (s *public) parseSince(r *http.Request) (time.Time, error) {
|
||||
}
|
||||
return sinceTS, nil
|
||||
}
|
||||
|
||||
// limitComments returns limited list of comments and count of comments left after limit.
|
||||
// If offsetID is provided, the list will be sliced starting from the comment with this ID.
|
||||
// If offsetID is not found, the full list will be returned.
|
||||
// It's used for only "
|
||||
func limitComments(c []store.Comment, limit int, offsetID string) (comments []store.Comment, countLeft int) {
|
||||
if offsetID != "" {
|
||||
for i, comment := range c {
|
||||
if comment.ID == offsetID {
|
||||
c = c[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if limit > 0 && len(c) > limit {
|
||||
countLeft = len(c) - limit
|
||||
c = c[:limit]
|
||||
}
|
||||
|
||||
return c, countLeft
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
"github.com/go-chi/chi/v5"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -83,6 +90,24 @@ func TestRest_Preview(t *testing.T) {
|
||||
string(b),
|
||||
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
|
||||
)
|
||||
|
||||
// test quotes with and without smartypants
|
||||
resp, err = post(t, ts.URL+"/api/v1/preview", `{"text": "\"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, "<p>«quoted» text</p>\n", string(b))
|
||||
|
||||
srv.privRest.disableFancyTextFormatting = true
|
||||
resp, err = post(t, ts.URL+"/api/v1/preview", `{"text": "\"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
b, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, "<p>"quoted" text</p>\n", string(b))
|
||||
}
|
||||
|
||||
func TestRest_PreviewWithWrongImage(t *testing.T) {
|
||||
@@ -120,9 +145,9 @@ srv, ts := prep(t)
|
||||
}
|
||||
BKT
|
||||
`
|
||||
text = strings.Replace(text, "BKT", "```", -1)
|
||||
text = strings.ReplaceAll(text, "BKT", "```")
|
||||
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
|
||||
j = strings.Replace(j, "\n", "\\n", -1)
|
||||
j = strings.ReplaceAll(j, "\n", "\\n")
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/preview", j)
|
||||
assert.NoError(t, err)
|
||||
@@ -131,10 +156,10 @@ BKT
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
`<h1>h1</h1>
|
||||
<pre class="chroma"><code><span class="line"><span class="cl">func TestRest_Preview(t *testing.T) {
|
||||
</span></span><span class="line"><span class="cl">srv, ts := prep(t)
|
||||
</span></span><span class="line"><span class="cl"> require.NotNil(t, srv)
|
||||
</span></span><span class="line"><span class="cl">}
|
||||
<pre class="chroma"><code><span class="line"><span class="cl"><span class="k">func</span> <span class="n">TestRest_Preview</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
|
||||
</span></span><span class="line"><span class="cl"><span class="n">srv</span><span class="p">,</span> <span class="n">ts</span> <span class="p">:</span><span class="o">=</span> <span class="n">prep</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>
|
||||
</span></span><span class="line"><span class="cl"> <span class="n">require</span><span class="o">.</span><span class="n">NotNil</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">srv</span><span class="p">)</span>
|
||||
</span></span><span class="line"><span class="cl"><span class="p">}</span>
|
||||
</span></span></code></pre>`,
|
||||
string(b))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
@@ -148,17 +173,17 @@ func TestRest_PreviewCode(t *testing.T) {
|
||||
func main(aa string) int {return 0}
|
||||
BKT
|
||||
`
|
||||
text = strings.Replace(text, "BKT", "```", -1)
|
||||
text = strings.ReplaceAll(text, "BKT", "```")
|
||||
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
|
||||
j = strings.Replace(j, "\n", "\\n", -1)
|
||||
j = strings.ReplaceAll(j, "\n", "\\n")
|
||||
|
||||
resp, err := post(t, ts.URL+"/api/v1/preview", j)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">(</span><span class="nx">aa</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span><span class="k">return</span> <span class="mi">0</span><span class="p">}</span>
|
||||
</span></span></code></pre>`, string(b))
|
||||
assert.Equal(t, `<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">main</span><span class="p">(</span><span class="nx">aa</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="p">{</span><span class="k">return</span><span class="w"> </span><span class="mi">0</span><span class="p">}</span><span class="w">
|
||||
</span></span></span></code></pre>`, string(b))
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
}
|
||||
|
||||
@@ -209,7 +234,7 @@ func TestRest_Find(t *testing.T) {
|
||||
assert.Equal(t, id2, comments.Comments[0].ID)
|
||||
|
||||
// get in tree mode
|
||||
tree := service.Tree{}
|
||||
tree := treeWithInfo{}
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
err = json.Unmarshal([]byte(res), &tree)
|
||||
@@ -235,7 +260,7 @@ func TestRest_FindAge(t *testing.T) {
|
||||
_, err = srv.DataService.Create(c2)
|
||||
require.NoError(t, err)
|
||||
|
||||
tree := service.Tree{}
|
||||
tree := treeWithInfo{}
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
@@ -278,7 +303,7 @@ func TestRest_FindReadOnly(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
tree := service.Tree{}
|
||||
tree := treeWithInfo{}
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
err = json.Unmarshal([]byte(res), &tree)
|
||||
@@ -286,7 +311,7 @@ func TestRest_FindReadOnly(t *testing.T) {
|
||||
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
|
||||
assert.True(t, tree.Info.ReadOnly, "post is ro")
|
||||
|
||||
tree = service.Tree{}
|
||||
tree = treeWithInfo{}
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
err = json.Unmarshal([]byte(res), &tree)
|
||||
@@ -477,6 +502,288 @@ func TestRest_FindUserComments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_FindUserComments_CWE_918(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}, []string{"radio-t.com"}) // required for extracting the title, bad URL test
|
||||
defer srv.DataService.TitleExtractor.Close()
|
||||
defer teardown()
|
||||
|
||||
backendRequestedArbitraryServer := false
|
||||
arbitraryServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
t.Logf("request received: %+v", r)
|
||||
backendRequestedArbitraryServer = true
|
||||
}))
|
||||
defer arbitraryServer.Close()
|
||||
|
||||
arbitraryURLComment := store.Comment{Text: "arbitrary URL request test",
|
||||
Locator: store.Locator{SiteID: "remark42", URL: arbitraryServer.URL}}
|
||||
|
||||
assert.False(t, backendRequestedArbitraryServer)
|
||||
addComment(t, arbitraryURLComment, ts)
|
||||
assert.False(t, backendRequestedArbitraryServer,
|
||||
"no request is expected to the test server as it's not in the list of the allowed domains for the title extractor")
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev")
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
|
||||
resp := struct {
|
||||
Comments []store.Comment
|
||||
Count int
|
||||
}{}
|
||||
|
||||
err := json.Unmarshal([]byte(res), &resp)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 1, len(resp.Comments), "should have 2 comments")
|
||||
|
||||
assert.Equal(t, "", resp.Comments[0].PostTitle, "empty from the first post")
|
||||
assert.Equal(t, arbitraryServer.URL, resp.Comments[0].Locator.URL, "arbitrary URL provided by the request")
|
||||
}
|
||||
|
||||
func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
|
||||
// test that comment counting is consistent between tree and plain formats
|
||||
ts, srv, teardown := startupT(t)
|
||||
defer teardown()
|
||||
|
||||
commentLocator := store.Locator{URL: "test-url", SiteID: "remark42"}
|
||||
|
||||
// vote for comment multiple times
|
||||
setScore := func(locator store.Locator, id string, val int) {
|
||||
abs := func(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
for i := 0; i < abs(val); i++ {
|
||||
_, err := srv.DataService.Vote(service.VoteReq{
|
||||
Locator: locator,
|
||||
CommentID: id,
|
||||
// unique user ID is needed for correct counting of controversial votes
|
||||
UserID: "user" + strconv.Itoa(val) + strconv.Itoa(i),
|
||||
Val: val > 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
c1 := store.Comment{Text: "top-level comment 1", Locator: commentLocator}
|
||||
ids[0], timestamps[0] = addCommentGetCreatedTime(t, c1, ts)
|
||||
// #3 by score
|
||||
setScore(commentLocator, ids[0], 1)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c2 := store.Comment{Text: "top-level comment 2", Locator: commentLocator}
|
||||
ids[1], timestamps[1] = addCommentGetCreatedTime(t, c2, ts)
|
||||
// #2 by score
|
||||
setScore(commentLocator, ids[1], 2)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c3 := store.Comment{Text: "second-level comment 1", ParentID: ids[0], Locator: commentLocator}
|
||||
ids[2], timestamps[2] = addCommentGetCreatedTime(t, c3, ts)
|
||||
// #1 by score
|
||||
setScore(commentLocator, ids[2], 10)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c4 := store.Comment{Text: "third-level comment 1", ParentID: ids[2], Locator: commentLocator}
|
||||
ids[3], timestamps[3] = addCommentGetCreatedTime(t, c4, ts)
|
||||
// #5 by score, #1 by controversy
|
||||
setScore(commentLocator, ids[3], 4)
|
||||
setScore(commentLocator, ids[3], -4)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c5 := store.Comment{Text: "second-level comment 2", ParentID: ids[1], Locator: commentLocator}
|
||||
ids[4], timestamps[4] = addCommentGetCreatedTime(t, c5, ts)
|
||||
// #5 by score, #2 by controversy
|
||||
setScore(commentLocator, ids[4], 2)
|
||||
setScore(commentLocator, ids[4], -3)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c6 := store.Comment{Text: "deleted third-level comment 2", ParentID: ids[4], Locator: commentLocator}
|
||||
ids[5], timestamps[5] = addCommentGetCreatedTime(t, c6, ts)
|
||||
// deleted later so not visible in site-wide requests
|
||||
setScore(commentLocator, ids[5], 10)
|
||||
setScore(commentLocator, ids[5], -10)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c7 := store.Comment{Text: "top-level comment 3", Locator: commentLocator}
|
||||
ids[6], timestamps[6] = addCommentGetCreatedTime(t, c7, ts)
|
||||
// #6 by score, #4 by controversy
|
||||
setScore(commentLocator, ids[6], -3)
|
||||
setScore(commentLocator, ids[6], 1)
|
||||
time.Sleep(time.Millisecond * 5)
|
||||
|
||||
c8 := store.Comment{Text: "deleted second-level comment 3", ParentID: ids[6], Locator: commentLocator}
|
||||
ids[7], timestamps[7] = addCommentGetCreatedTime(t, c8, ts)
|
||||
// deleted later so not visible in site-wide requests
|
||||
setScore(commentLocator, ids[7], -20)
|
||||
|
||||
c9 := store.Comment{Text: "comment to post 2", Locator: store.Locator{URL: "another-url", SiteID: "remark42"}}
|
||||
ids[8], timestamps[8] = addCommentGetCreatedTime(t, c9, ts)
|
||||
// #7 by score
|
||||
setScore(store.Locator{URL: "another-url", SiteID: "remark42"}, ids[8], -25)
|
||||
|
||||
// delete two comments bringing the total from 9 to 6
|
||||
err := srv.DataService.Delete(commentLocator, ids[7], store.SoftDelete)
|
||||
assert.NoError(t, err)
|
||||
err = srv.DataService.Delete(commentLocator, ids[5], store.HardDelete)
|
||||
assert.NoError(t, err)
|
||||
srv.Cache.Flush(cache.FlusherRequest{})
|
||||
|
||||
commentLocator.URL = "readonly-test"
|
||||
// set post without comments to read-only
|
||||
assert.NoError(t, srv.DataService.SetReadOnly(commentLocator, true))
|
||||
|
||||
sinceTenSecondsAgo := strconv.FormatInt(time.Now().Add(-time.Second*10).UnixNano()/1000000, 10)
|
||||
sinceTS := make([]string, 9)
|
||||
formattedTS := make([]string, 9)
|
||||
for i, created := range timestamps {
|
||||
sinceTS[i] = strconv.FormatInt(created.UnixNano()/1000000, 10)
|
||||
formattedTS[i] = created.Format(time.RFC3339Nano)
|
||||
}
|
||||
t.Logf("last timestamp: %v", timestamps[7])
|
||||
|
||||
testCases := []struct {
|
||||
params string
|
||||
expectedBody string
|
||||
}{
|
||||
// test parameters url, format, since, sort
|
||||
{"", fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||
{"url=test-url", fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||
{"format=plain", fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||
{"format=plain&url=test-url", fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||
{"since=" + sinceTenSecondsAgo, fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||
{"url=test-url&since=" + sinceTenSecondsAgo, fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||
{"since=" + sinceTS[0], fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||
{"url=test-url&since=" + sinceTS[0], fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||
{"since=" + sinceTS[1], fmt.Sprintf(`"info":{"count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||
{"url=test-url&since=" + sinceTS[1], fmt.Sprintf(`"info":{"url":"test-url","count":5,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||
{"since=" + sinceTS[4], fmt.Sprintf(`"info":{"count":3,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||
{"url=test-url&since=" + sinceTS[4], fmt.Sprintf(`"info":{"url":"test-url","count":2,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||
{"format=tree", `"info":{"count":7`},
|
||||
{"format=tree&url=test-url", `"info":{"url":"test-url","count":6`},
|
||||
{"format=tree&sort=+time", `"info":{"count":7`},
|
||||
{"format=tree&url=test-url&sort=+time", `"info":{"url":"test-url","count":6`},
|
||||
{"format=tree&sort=-score", `"info":{"count":7`},
|
||||
{"format=tree&url=test-url&sort=-score", `"info":{"url":"test-url","count":6`},
|
||||
{"sort=+time", fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[8])},
|
||||
{"sort=-time", fmt.Sprintf(`"score":1,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[0])},
|
||||
{"sort=+score", fmt.Sprintf(`"score":10,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[2])},
|
||||
{"sort=+score&url=test-url", fmt.Sprintf(`"score":10,"vote":0,"time":%q}],"info":{"url":"test-url","count":6`, formattedTS[2])},
|
||||
{"sort=-score", fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[8])},
|
||||
{"sort=-score&url=test-url", fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":6`, formattedTS[6])},
|
||||
{"sort=-time&since=" + sinceTS[4], fmt.Sprintf(`"score":-1,"vote":0,"controversy":2.924017738212866,"time":%q}],"info":{"count":3`, formattedTS[4])},
|
||||
{"sort=-score&since=" + sinceTS[3], fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":4`, formattedTS[8])},
|
||||
{"sort=-score&url=test-url&since=" + sinceTS[3], fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":3`, formattedTS[6])},
|
||||
{"sort=+controversy&url=test-url&since=" + sinceTS[5], fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":1`, formattedTS[6])},
|
||||
// three comments of which last one deleted and doesn't have controversy so returned last
|
||||
{"sort=-controversy&url=test-url&since=" + sinceTS[5], fmt.Sprintf(`"score":0,"vote":0,"time":%q,"delete":true}],"info":{"url":"test-url","count":1`, formattedTS[7])},
|
||||
// test readonly status for the post without comments
|
||||
{"url=readonly-test", `"info":{"count":0,"count_left":0,"read_only":true`},
|
||||
{"format=tree&url=readonly-test", `"info":{"count":0,"count_left":0,"read_only":true`},
|
||||
|
||||
// test parameters limit, offset_id for format=plain
|
||||
{"limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`},
|
||||
{"offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`},
|
||||
{"limit=2", `"info":{"count":7,"count_left":5,"last_comment":"` + ids[1]},
|
||||
{"limit=6", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"limit=7", `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||
{"limit=2&url=test-url", `"info":{"url":"test-url","count":6,"count_left":6,"last_comment":"` + ids[1]},
|
||||
{"limit=6&url=test-url", `"info":{"url":"test-url","count":6,"count_left":2,"last_comment":"` + ids[5]},
|
||||
{"limit=7&url=test-url", `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{fmt.Sprintf("limit=2&offset_id=%s", ids[2]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[4]},
|
||||
{fmt.Sprintf("limit=2&offset_id=%s", ids[3]), `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{fmt.Sprintf("limit=2&offset_id=%s", ids[4]), `"info":{"count":7,"count_left":0`},
|
||||
{fmt.Sprintf("limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0`},
|
||||
{fmt.Sprintf("limit=2&offset_id=%s", ids[8]), `"info":{"count":7,"count_left":0`},
|
||||
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[2]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[4]},
|
||||
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[3]), `"info":{"url":"test-url","count":6,"count_left":2,"last_comment":"` + ids[5]},
|
||||
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[4]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{fmt.Sprintf("limit=1&url=test-url&offset_id=%s", ids[6]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[7]},
|
||||
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[8]), `"info":{"url":"test-url","count":6,"count_left":6,`},
|
||||
// deleted comment, offset is ignored in site-wide request but not for particular URL
|
||||
{fmt.Sprintf("limit=2&offset_id=%s", ids[5]), `"info":{"count":7,"count_left":5,"last_comment":"` + ids[1]},
|
||||
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[5]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[7]},
|
||||
// non-existing comment, offset is ignored, deleted comments included into request with "url"
|
||||
{fmt.Sprintf("limit=1&offset_id=%s", uuid.New().String()), `"info":{"count":7,"count_left":6,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("limit=1&url=test-url&offset_id=%s", uuid.New().String()), `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[0]},
|
||||
// since is ignored for tree format, so we test it only for plain
|
||||
{"limit=6&since=" + sinceTenSecondsAgo, `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"limit=1&since=" + sinceTS[4], `"info":{"count":3,"count_left":2,"last_comment":"` + ids[4]},
|
||||
{"limit=6&url=test-url&since=" + sinceTenSecondsAgo, `"info":{"url":"test-url","count":6,"count_left":2,"last_comment":"` + ids[5]},
|
||||
{"limit=1&url=test-url&since=" + sinceTS[4], `"info":{"url":"test-url","count":2,"count_left":3,"last_comment":"` + ids[4]},
|
||||
// start with deleted comment timestamp
|
||||
{"limit=1&since=" + sinceTS[5], `"info":{"count":2,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"limit=1&since=" + sinceTS[6], `"info":{"count":2,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"limit=1&url=test-url&since=" + sinceTS[5], `"info":{"url":"test-url","count":1,"count_left":2,"last_comment":"` + ids[5]},
|
||||
{"limit=1&url=test-url&since=" + sinceTS[6], `"info":{"url":"test-url","count":1,"count_left":1,"last_comment":"` + ids[6]},
|
||||
// test sort
|
||||
{"limit=1&sort=+time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[0]},
|
||||
{"limit=1&sort=-time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[7]},
|
||||
{"limit=1&sort=+score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[6]},
|
||||
{"limit=1&sort=-score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[2]},
|
||||
{"limit=1&sort=+controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[0]},
|
||||
{"limit=1&sort=-controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[3]},
|
||||
|
||||
// test parameters limit, offset_id for format=tree
|
||||
{"format=tree&limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`},
|
||||
{"format=tree&offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`},
|
||||
{"format=tree&limit=2", `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{"format=tree&limit=6", `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]},
|
||||
{"format=tree&limit=7", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{"format=tree&url=test-url&limit=2", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{"format=tree&url=test-url&limit=6", `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]},
|
||||
{"format=tree&url=test-url&limit=7", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||
// start after first top-level comment
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[0]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[0]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]},
|
||||
// start after second top-level comment
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[1]), `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[1]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||
// start after third top-level comment, so expect comment to post 2, or no comments on post 1 if "url" is set
|
||||
{fmt.Sprintf("format=tree&limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=1&offset_id=%s", ids[6]), `"info":{"url":"test-url","count":6,"count_left":0`},
|
||||
// non-root comment IDs or non-existing IDs are ignored
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[2]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[3]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[4]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[7]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&limit=1&offset_id=%s", uuid.New().String()), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[2]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[3]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[4]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[7]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{fmt.Sprintf("format=tree&url=test-url&limit=1&offset_id=%s", uuid.New().String()), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
// test sort
|
||||
{"format=tree&limit=1&sort=+time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{"format=tree&limit=1&sort=-time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":5,"last_comment":"` + ids[6]},
|
||||
{"format=tree&limit=1&sort=+score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":5,"last_comment":"` + ids[6]},
|
||||
{"format=tree&limit=1&sort=-score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":4,"last_comment":"` + ids[1]},
|
||||
{"format=tree&limit=1&sort=+controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||
{"format=tree&limit=1&sort=-controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":5,"last_comment":"` + ids[6]},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.params, func(t *testing.T) {
|
||||
url := fmt.Sprintf(ts.URL+"/api/v1/find?site=remark42&%s", tc.params)
|
||||
body, code := get(t, url)
|
||||
expectedStatus := http.StatusOK
|
||||
if strings.Contains(tc.params, "=bad") {
|
||||
expectedStatus = http.StatusBadRequest
|
||||
}
|
||||
assert.Equal(t, expectedStatus, code)
|
||||
assert.Contains(t, body, tc.expectedBody)
|
||||
t.Log(body)
|
||||
// prevent hit limiter from engaging
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRest_UserInfo(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
@@ -627,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"])
|
||||
@@ -725,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"))
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/auth"
|
||||
"github.com/go-pkgz/auth/avatar"
|
||||
"github.com/go-pkgz/auth/provider"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
"github.com/go-pkgz/auth/v2"
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
"github.com/go-pkgz/auth/v2/provider"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -128,7 +128,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
|
||||
@@ -178,7 +178,7 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRest_rejectAnonUser(t *testing.T) {
|
||||
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprintln(w, "Hello")
|
||||
}))))
|
||||
defer ts.Close()
|
||||
@@ -227,7 +227,6 @@ func Test_URLKey(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
@@ -252,7 +251,6 @@ func Test_URLKeyWithUser(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
||||
require.NoError(t, err)
|
||||
@@ -279,7 +277,6 @@ func TestRest_parseError(t *testing.T) {
|
||||
}
|
||||
|
||||
for n, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(n), func(t *testing.T) {
|
||||
res := parseError(tt.err, rest.ErrInternal)
|
||||
assert.Equal(t, tt.res, res)
|
||||
@@ -302,12 +299,11 @@ func TestRest_cacheControl(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.url, http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -320,30 +316,96 @@ func TestRest_cacheControl(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRest_frameAncestors(t *testing.T) {
|
||||
tbl := []struct {
|
||||
hosts []string
|
||||
header string
|
||||
}{
|
||||
{[]string{"http://example.com"}, "frame-ancestors http://example.com;"},
|
||||
{[]string{}, ""},
|
||||
{[]string{"http://example.com", "http://example2.com"}, "frame-ancestors http://example.com http://example2.com;"},
|
||||
}
|
||||
ts, _, teardown := startupT(t, func(o *Rest) {
|
||||
o.AllowedAncestors = []string{"'self'", "https://example.com"}
|
||||
})
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
// test case with frame-ancestors
|
||||
client := http.Client{}
|
||||
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
|
||||
teardown()
|
||||
|
||||
h := frameAncestors(tt.hosts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
t.Logf("%+v", resp.Header)
|
||||
assert.Equal(t, tt.header, resp.Header.Get("Content-Security-Policy"))
|
||||
})
|
||||
}
|
||||
// test case without frame-ancestors
|
||||
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||
srv.AllowedAncestors = []string{}
|
||||
})
|
||||
defer teardown()
|
||||
resp, err = client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;")
|
||||
}
|
||||
|
||||
// TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none'
|
||||
// override regardless of what the global CSP allows. The widget HTML pages
|
||||
// (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap),
|
||||
// so the test asserts the two policies diverge across origins.
|
||||
func TestRest_apiCSP(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
defer teardown()
|
||||
client := http.Client{}
|
||||
|
||||
// JSON API endpoint — must carry the strict policy
|
||||
resp, err := client.Get(ts.URL + "/api/v1/config")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
csp := resp.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp, "default-src 'none'",
|
||||
"API responses must override the global CSP with default-src 'none'; got %q", csp)
|
||||
assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp)
|
||||
assert.NotContains(t, csp, "'unsafe-inline'",
|
||||
"API CSP must not allow inline scripts/styles; got %q", csp)
|
||||
|
||||
// RSS/XML endpoint — same strict policy, and the XML response itself must still be served
|
||||
respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42")
|
||||
require.NoError(t, err)
|
||||
defer respRSS.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP")
|
||||
cspRSS := respRSS.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP")
|
||||
assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox")
|
||||
|
||||
// widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap)
|
||||
resp2, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
csp2 := resp2.Header.Get("Content-Security-Policy")
|
||||
assert.Contains(t, csp2, "'unsafe-inline'",
|
||||
"widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2)
|
||||
}
|
||||
|
||||
// check CSP, img-src should be 'self' with proxy enabled and * without it
|
||||
func TestRest_securityHeaders(t *testing.T) {
|
||||
ts, _, teardown := startupT(t)
|
||||
|
||||
// with proxy disabled
|
||||
client := http.Client{}
|
||||
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
teardown()
|
||||
|
||||
// check CSP with proxy enabled
|
||||
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||
srv.ExternalImageProxy = true
|
||||
})
|
||||
defer teardown()
|
||||
resp, err = client.Get(ts.URL + "/web/index.html")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';")
|
||||
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||
}
|
||||
|
||||
func TestRest_subscribersOnly(t *testing.T) {
|
||||
@@ -364,14 +426,13 @@ func TestRest_subscribersOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
|
||||
if tt.setUser {
|
||||
req = token.SetUserInfo(req, tt.user)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, tt.status, resp.StatusCode)
|
||||
@@ -403,7 +464,7 @@ func Test_validEmailAuth(t *testing.T) {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
|
||||
w := httptest.NewRecorder()
|
||||
h := validEmailAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(w, req)
|
||||
resp := w.Result()
|
||||
assert.Equal(t, tt.status, resp.StatusCode)
|
||||
@@ -414,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)
|
||||
@@ -438,7 +499,7 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"})
|
||||
require.NoError(t, err)
|
||||
|
||||
memCache := cache.NewScache(cache.NewNopCache())
|
||||
memCache := cache.NewScache[[]byte](cache.NewNopCache[[]byte]())
|
||||
|
||||
astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com")
|
||||
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
|
||||
@@ -458,7 +519,7 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
||||
DataService: dataStore,
|
||||
Authenticator: auth.NewService(auth.Opts{
|
||||
AdminPasswd: "password",
|
||||
SecretReader: token.SecretFunc(func(aud string) (string, error) { return "secret", nil }),
|
||||
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
|
||||
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
|
||||
}),
|
||||
Cache: memCache,
|
||||
@@ -487,15 +548,16 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
||||
Cache: memCache,
|
||||
KeyStore: astore,
|
||||
},
|
||||
NotifyService: notify.NopService,
|
||||
EmojiEnabled: true,
|
||||
NotifyService: notify.NopService,
|
||||
EmojiEnabled: true,
|
||||
openRouteLimiter: 100,
|
||||
}
|
||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
|
||||
|
||||
// add some providers. Needed because we don't allow users with unlisted providers to authenticate
|
||||
providers := []string{"provider1", "anonymous", "github", "email"}
|
||||
for _, p := range providers {
|
||||
srv.Authenticator.AddDirectProvider(p, provider.CredCheckerFunc(func(user, password string) (ok bool, err error) {
|
||||
srv.Authenticator.AddDirectProvider(p, provider.CredCheckerFunc(func(_, _ string) (ok bool, err error) {
|
||||
return true, nil
|
||||
}))
|
||||
}
|
||||
@@ -504,7 +566,8 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
||||
h(srv)
|
||||
}
|
||||
|
||||
ts = httptest.NewServer(srv.routes())
|
||||
routes := srv.routes()
|
||||
ts = httptest.NewServer(routes)
|
||||
|
||||
teardown = func() {
|
||||
ts.Close()
|
||||
@@ -599,13 +662,17 @@ func post(t *testing.T, url, body string) (*http.Response, error) {
|
||||
return client.Do(req)
|
||||
}
|
||||
|
||||
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
||||
func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server) (id string, created time.Time) {
|
||||
b, err := json.Marshal(c)
|
||||
require.NoError(t, err, "can't marshal comment %+v", c)
|
||||
|
||||
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)
|
||||
@@ -619,7 +686,14 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
||||
err = json.Unmarshal(b, &crResp)
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Nanosecond * 10)
|
||||
return crResp["id"].(string)
|
||||
created, err = time.Parse(time.RFC3339, crResp["time"].(string))
|
||||
require.NoError(t, err)
|
||||
return crResp["id"].(string), created
|
||||
}
|
||||
|
||||
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
||||
id, _ := addCommentGetCreatedTime(t, c, ts)
|
||||
return id
|
||||
}
|
||||
|
||||
func requireAdminOnly(t *testing.T, req *http.Request) {
|
||||
@@ -635,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()
|
||||
@@ -647,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 {
|
||||
@@ -658,5 +732,49 @@ func waitForHTTPSServerStart(port int) {
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
goleak.VerifyTestMain(
|
||||
m,
|
||||
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
cache "github.com/go-pkgz/lcw/v2"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/gorilla/feeds"
|
||||
|
||||
@@ -56,8 +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)
|
||||
}
|
||||
}
|
||||
@@ -88,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)
|
||||
}
|
||||
}
|
||||
@@ -120,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,10 +271,7 @@ func TestServer_RssReplies(t *testing.T) {
|
||||
}
|
||||
|
||||
func waitOnSecChange() {
|
||||
for {
|
||||
if time.Now().Nanosecond() < 100000000 {
|
||||
break
|
||||
}
|
||||
for time.Now().Nanosecond() >= 100000000 {
|
||||
time.Sleep(10 * time.Nanosecond)
|
||||
}
|
||||
}
|
||||
@@ -283,11 +280,11 @@ func waitOnSecChange() {
|
||||
func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) {
|
||||
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
|
||||
|
||||
expected = strings.Replace(expected, "\n", " ", -1)
|
||||
expected = strings.Replace(expected, "\t", " ", -1)
|
||||
expected = strings.ReplaceAll(expected, "\n", " ")
|
||||
expected = strings.ReplaceAll(expected, "\t", " ")
|
||||
expected = reSpaces.ReplaceAllString(expected, " ")
|
||||
|
||||
actual = strings.Replace(actual, "\n", " ", -1)
|
||||
actual = strings.ReplaceAll(actual, "\n", " ")
|
||||
actual = reSpaces.ReplaceAllString(actual, " ")
|
||||
return expected, actual
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestSSL_Redirect(t *testing.T) {
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
|
||||
|
||||
client := http.Client{
|
||||
// prevent http redirect
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"version": 1,
|
||||
"comments": [
|
||||
{
|
||||
"commentHex": "e7a2ef4b4aa1414a7ee65a989889aaecd9d5e7e3bca598ea7a967b4dbcaa8e11",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2022-10-25T07:25:46.807555Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "a29e741145daceb4ca5b3e5e279e05b56f73c04703d93b944718ef757e15317f",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2023-07-26T12:24:55.058552Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2023-10-31T11:03:25.403282Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "6d3bb64ff73b5f9d6a959212ffde472a51abf8bdefaa5ed843659796bceef9de",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2023-11-01T22:23:47.112062Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2023-10-23T12:33:03.370182Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "d0ad6f11cf0c5f8e17457a378a6bb789f412c6b7ef7ada4ae06ec8451f7a18aa",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2023-10-18T01:18:38.193625Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "098960fd01c1fc7c0d3ea428f52fab97ea5c18aa52f3565bba679224daddc687",
|
||||
"domain": "example.com",
|
||||
"url": "/example",
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"markdown": "",
|
||||
"html": "",
|
||||
"parentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2023-11-01T22:24:04.639965Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
}
|
||||
],
|
||||
"commenters": [
|
||||
{
|
||||
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||
"email": "undefined",
|
||||
"name": "blank",
|
||||
"link": "undefined",
|
||||
"photo": "undefined",
|
||||
"provider": "anon",
|
||||
"joinDate": "2022-06-09T15:54:29.865919Z",
|
||||
"isModerator": false,
|
||||
"deleted": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/render"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/go-pkgz/rest"
|
||||
|
||||
@@ -52,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)
|
||||
}
|
||||
@@ -67,20 +66,36 @@ func SendErrorHTML(w http.ResponseWriter, r *http.Request, httpStatusCode int, e
|
||||
tmplstr := MustRead("error_response.html.tmpl")
|
||||
tmpl := template.Must(template.New("error").Parse(tmplstr))
|
||||
log.Printf("[WARN] %s", errDetailsMsg(r, httpStatusCode, err, details, errCode))
|
||||
render.Status(r, httpStatusCode)
|
||||
|
||||
msg := bytes.Buffer{}
|
||||
MustExecute(tmpl, &msg, errTmplData{
|
||||
Error: err.Error(),
|
||||
Details: details,
|
||||
})
|
||||
render.HTML(w, r, msg.String())
|
||||
|
||||
HTMLResponse(w, httpStatusCode, msg.String())
|
||||
}
|
||||
|
||||
// SendErrorJSON makes {error: blah, details: blah, code: 42} json body and responds with error code
|
||||
func SendErrorJSON(w http.ResponseWriter, r *http.Request, httpStatusCode int, err error, details string, errCode int) {
|
||||
log.Printf("[WARN] %s", errDetailsMsg(r, httpStatusCode, err, details, errCode))
|
||||
render.Status(r, httpStatusCode)
|
||||
render.JSON(w, r, rest.JSON{"error": err.Error(), "details": details, "code": errCode})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(httpStatusCode)
|
||||
rest.RenderJSON(w, rest.JSON{"error": err.Error(), "details": details, "code": errCode})
|
||||
}
|
||||
|
||||
// HTMLResponse writes HTML content with the given status code
|
||||
func HTMLResponse(w http.ResponseWriter, status int, html string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// PlainTextResponse writes plain text content with the given status code
|
||||
func PlainTextResponse(w http.ResponseWriter, status int, text string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(text))
|
||||
}
|
||||
|
||||
func errDetailsMsg(r *http.Request, httpStatusCode int, err error, details string, errCode int) string {
|
||||
|
||||
@@ -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, ";")
|
||||
})
|
||||
}
|
||||
}
|
||||
+114
-26
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -12,12 +13,18 @@ import (
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/go-pkgz/repeater"
|
||||
"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
|
||||
@@ -57,7 +69,7 @@ func (p Image) extract(commentHTML string, imgSrcPred func(string) bool) ([]stri
|
||||
return nil, fmt.Errorf("can't create document: %w", err)
|
||||
}
|
||||
result := []string{}
|
||||
doc.Find("img").Each(func(i int, s *goquery.Selection) {
|
||||
doc.Find("img").Each(func(_ int, s *goquery.Selection) {
|
||||
if im, ok := s.Attr("src"); ok {
|
||||
if imgSrcPred(im) {
|
||||
result = append(result, im)
|
||||
@@ -72,33 +84,73 @@ func (p Image) replace(commentHTML string, imgs []string) string {
|
||||
for _, img := range imgs {
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(img))
|
||||
resImgURL := p.RemarkURL + p.RoutePath + "?src=" + encodedImgURL
|
||||
commentHTML = strings.Replace(commentHTML, img, resImgURL, -1)
|
||||
commentHTML = strings.ReplaceAll(commentHTML, img, resImgURL)
|
||||
}
|
||||
|
||||
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 {
|
||||
rest.SendErrorJSON(w, r, http.StatusNotFound, err, "can't get image "+imgURL, rest.ErrAssetNotFound)
|
||||
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
|
||||
}
|
||||
sendImageProxyError(w, r, http.StatusNotFound, fmt.Errorf("failed to fetch"), "can't get image", rest.ErrAssetNotFound)
|
||||
return
|
||||
}
|
||||
if p.CacheExternal {
|
||||
@@ -106,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)
|
||||
@@ -144,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.NewDefault(5, time.Second).Do(ctx, func() error {
|
||||
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 {
|
||||
@@ -165,9 +240,22 @@ func (p Image) downloadImage(ctx context.Context, imgURL string) ([]byte, error)
|
||||
return nil, fmt.Errorf("got unsuccessful response status %d while fetching %s", resp.StatusCode, imgURL)
|
||||
}
|
||||
|
||||
imgData, err := io.ReadAll(resp.Body)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.HasPrefix(contentType, "image/") {
|
||||
return nil, fmt.Errorf("%w: %s", errInvalidUpstreamContentType, contentType)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ func TestImage_Extract(t *testing.T) {
|
||||
img := Image{HTTP2HTTPS: true}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
res, err := img.extract(tt.inp, func(src string) bool { return strings.HasPrefix(src, "http://") })
|
||||
assert.NoError(t, err)
|
||||
@@ -95,12 +94,13 @@ func TestImage_Replace(t *testing.T) {
|
||||
|
||||
func TestImage_Routes(t *testing.T) {
|
||||
// no image supposed to be cached
|
||||
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { return nil, nil }}
|
||||
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) { return nil, nil }}
|
||||
img := Image{
|
||||
HTTP2HTTPS: true,
|
||||
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))
|
||||
@@ -108,35 +108,50 @@ func TestImage_Routes(t *testing.T) {
|
||||
httpSrv := imgHTTPTestsServer(t)
|
||||
defer httpSrv.Close()
|
||||
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/img1.png"))
|
||||
t.Run("valid image", func(t *testing.T) {
|
||||
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, "1462", resp.Header["Content-Length"][0])
|
||||
assert.Equal(t, "image/png", resp.Header["Content-Type"][0])
|
||||
})
|
||||
|
||||
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, "1462", resp.Header["Content-Length"][0])
|
||||
assert.Equal(t, "image/png", resp.Header["Content-Type"][0])
|
||||
t.Run("no image", func(t *testing.T) {
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
|
||||
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "/image/no-such-image.png"))
|
||||
resp, err = http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
t.Run("bad encoding", func(t *testing.T) {
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "bad encoding"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(t, 2, len(imageStore.LoadCalls()))
|
||||
})
|
||||
|
||||
encodedImgURL = base64.URLEncoding.EncodeToString([]byte(httpSrv.URL + "bad encoding"))
|
||||
resp, err = http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(t, 2, len(imageStore.LoadCalls()))
|
||||
t.Run("non-image reference", func(t *testing.T) {
|
||||
encodedImgURL := base64.URLEncoding.EncodeToString([]byte("https://google.com"))
|
||||
resp, err := http.Get(ts.URL + "/?src=" + encodedImgURL)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(t, 3, len(imageStore.LoadCalls()))
|
||||
})
|
||||
}
|
||||
|
||||
func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) {
|
||||
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { return nil, nil }}
|
||||
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))
|
||||
@@ -158,10 +173,10 @@ func TestImage_DisabledCachingAndHTTP2HTTPS(t *testing.T) {
|
||||
|
||||
func TestImage_RoutesCachingImage(t *testing.T) {
|
||||
imageStore := image.StoreMock{
|
||||
LoadFunc: func(id string) ([]byte, error) {
|
||||
LoadFunc: func(string) ([]byte, error) {
|
||||
return nil, nil
|
||||
},
|
||||
SaveFunc: func(id string, img []byte) error {
|
||||
SaveFunc: func(string, []byte) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -170,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))
|
||||
@@ -194,45 +210,71 @@ 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(id 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) {
|
||||
// no image supposed to be cached
|
||||
imageStore := image.StoreMock{LoadFunc: func(id string) ([]byte, error) { return nil, nil }}
|
||||
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: 50 * time.Millisecond,
|
||||
ImageService: image.NewService(&imageStore, image.ServiceParams{}),
|
||||
Transport: http.DefaultTransport,
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(img.Handler))
|
||||
@@ -249,7 +291,8 @@ func TestImage_RoutesTimedOut(t *testing.T) {
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
require.NoError(t, err)
|
||||
t.Log(string(b))
|
||||
assert.True(t, strings.Contains(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()))
|
||||
}
|
||||
|
||||
@@ -291,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" {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-pkgz/auth/token"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
log "github.com/go-pkgz/lgr"
|
||||
)
|
||||
|
||||
// NOTE: matryer/moq should be installed globally and works with `go generate ./...`
|
||||
//go:generate moq --out admin_mock.go . Store
|
||||
|
||||
// Store defines interface returning admins info for given site
|
||||
type Store interface {
|
||||
Key(siteID string) (key string, err error)
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Code generated by moq; DO NOT EDIT.
|
||||
// github.com/matryer/moq
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Ensure, that StoreMock does implement Store.
|
||||
// If this is not the case, regenerate this file with moq.
|
||||
var _ Store = &StoreMock{}
|
||||
|
||||
// StoreMock is a mock implementation of Store.
|
||||
//
|
||||
// func TestSomethingThatUsesStore(t *testing.T) {
|
||||
//
|
||||
// // make and configure a mocked Store
|
||||
// mockedStore := &StoreMock{
|
||||
// AdminsFunc: func(siteID string) ([]string, error) {
|
||||
// panic("mock out the Admins method")
|
||||
// },
|
||||
// EmailFunc: func(siteID string) (string, error) {
|
||||
// panic("mock out the Email method")
|
||||
// },
|
||||
// EnabledFunc: func(siteID string) (bool, error) {
|
||||
// panic("mock out the Enabled method")
|
||||
// },
|
||||
// KeyFunc: func(siteID string) (string, error) {
|
||||
// panic("mock out the Key method")
|
||||
// },
|
||||
// OnEventFunc: func(siteID string, et EventType) error {
|
||||
// panic("mock out the OnEvent method")
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// // use mockedStore in code that requires Store
|
||||
// // and then make assertions.
|
||||
//
|
||||
// }
|
||||
type StoreMock struct {
|
||||
// AdminsFunc mocks the Admins method.
|
||||
AdminsFunc func(siteID string) ([]string, error)
|
||||
|
||||
// EmailFunc mocks the Email method.
|
||||
EmailFunc func(siteID string) (string, error)
|
||||
|
||||
// EnabledFunc mocks the Enabled method.
|
||||
EnabledFunc func(siteID string) (bool, error)
|
||||
|
||||
// KeyFunc mocks the Key method.
|
||||
KeyFunc func(siteID string) (string, error)
|
||||
|
||||
// OnEventFunc mocks the OnEvent method.
|
||||
OnEventFunc func(siteID string, et EventType) error
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// Admins holds details about calls to the Admins method.
|
||||
Admins []struct {
|
||||
// SiteID is the siteID argument value.
|
||||
SiteID string
|
||||
}
|
||||
// Email holds details about calls to the Email method.
|
||||
Email []struct {
|
||||
// SiteID is the siteID argument value.
|
||||
SiteID string
|
||||
}
|
||||
// Enabled holds details about calls to the Enabled method.
|
||||
Enabled []struct {
|
||||
// SiteID is the siteID argument value.
|
||||
SiteID string
|
||||
}
|
||||
// Key holds details about calls to the Key method.
|
||||
Key []struct {
|
||||
// SiteID is the siteID argument value.
|
||||
SiteID string
|
||||
}
|
||||
// OnEvent holds details about calls to the OnEvent method.
|
||||
OnEvent []struct {
|
||||
// SiteID is the siteID argument value.
|
||||
SiteID string
|
||||
// Et is the et argument value.
|
||||
Et EventType
|
||||
}
|
||||
}
|
||||
lockAdmins sync.RWMutex
|
||||
lockEmail sync.RWMutex
|
||||
lockEnabled sync.RWMutex
|
||||
lockKey sync.RWMutex
|
||||
lockOnEvent sync.RWMutex
|
||||
}
|
||||
|
||||
// Admins calls AdminsFunc.
|
||||
func (mock *StoreMock) Admins(siteID string) ([]string, error) {
|
||||
if mock.AdminsFunc == nil {
|
||||
panic("StoreMock.AdminsFunc: method is nil but Store.Admins was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
SiteID string
|
||||
}{
|
||||
SiteID: siteID,
|
||||
}
|
||||
mock.lockAdmins.Lock()
|
||||
mock.calls.Admins = append(mock.calls.Admins, callInfo)
|
||||
mock.lockAdmins.Unlock()
|
||||
return mock.AdminsFunc(siteID)
|
||||
}
|
||||
|
||||
// AdminsCalls gets all the calls that were made to Admins.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedStore.AdminsCalls())
|
||||
func (mock *StoreMock) AdminsCalls() []struct {
|
||||
SiteID string
|
||||
} {
|
||||
var calls []struct {
|
||||
SiteID string
|
||||
}
|
||||
mock.lockAdmins.RLock()
|
||||
calls = mock.calls.Admins
|
||||
mock.lockAdmins.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Email calls EmailFunc.
|
||||
func (mock *StoreMock) Email(siteID string) (string, error) {
|
||||
if mock.EmailFunc == nil {
|
||||
panic("StoreMock.EmailFunc: method is nil but Store.Email was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
SiteID string
|
||||
}{
|
||||
SiteID: siteID,
|
||||
}
|
||||
mock.lockEmail.Lock()
|
||||
mock.calls.Email = append(mock.calls.Email, callInfo)
|
||||
mock.lockEmail.Unlock()
|
||||
return mock.EmailFunc(siteID)
|
||||
}
|
||||
|
||||
// EmailCalls gets all the calls that were made to Email.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedStore.EmailCalls())
|
||||
func (mock *StoreMock) EmailCalls() []struct {
|
||||
SiteID string
|
||||
} {
|
||||
var calls []struct {
|
||||
SiteID string
|
||||
}
|
||||
mock.lockEmail.RLock()
|
||||
calls = mock.calls.Email
|
||||
mock.lockEmail.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Enabled calls EnabledFunc.
|
||||
func (mock *StoreMock) Enabled(siteID string) (bool, error) {
|
||||
if mock.EnabledFunc == nil {
|
||||
panic("StoreMock.EnabledFunc: method is nil but Store.Enabled was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
SiteID string
|
||||
}{
|
||||
SiteID: siteID,
|
||||
}
|
||||
mock.lockEnabled.Lock()
|
||||
mock.calls.Enabled = append(mock.calls.Enabled, callInfo)
|
||||
mock.lockEnabled.Unlock()
|
||||
return mock.EnabledFunc(siteID)
|
||||
}
|
||||
|
||||
// EnabledCalls gets all the calls that were made to Enabled.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedStore.EnabledCalls())
|
||||
func (mock *StoreMock) EnabledCalls() []struct {
|
||||
SiteID string
|
||||
} {
|
||||
var calls []struct {
|
||||
SiteID string
|
||||
}
|
||||
mock.lockEnabled.RLock()
|
||||
calls = mock.calls.Enabled
|
||||
mock.lockEnabled.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Key calls KeyFunc.
|
||||
func (mock *StoreMock) Key(siteID string) (string, error) {
|
||||
if mock.KeyFunc == nil {
|
||||
panic("StoreMock.KeyFunc: method is nil but Store.Key was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
SiteID string
|
||||
}{
|
||||
SiteID: siteID,
|
||||
}
|
||||
mock.lockKey.Lock()
|
||||
mock.calls.Key = append(mock.calls.Key, callInfo)
|
||||
mock.lockKey.Unlock()
|
||||
return mock.KeyFunc(siteID)
|
||||
}
|
||||
|
||||
// KeyCalls gets all the calls that were made to Key.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedStore.KeyCalls())
|
||||
func (mock *StoreMock) KeyCalls() []struct {
|
||||
SiteID string
|
||||
} {
|
||||
var calls []struct {
|
||||
SiteID string
|
||||
}
|
||||
mock.lockKey.RLock()
|
||||
calls = mock.calls.Key
|
||||
mock.lockKey.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// OnEvent calls OnEventFunc.
|
||||
func (mock *StoreMock) OnEvent(siteID string, et EventType) error {
|
||||
if mock.OnEventFunc == nil {
|
||||
panic("StoreMock.OnEventFunc: method is nil but Store.OnEvent was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
SiteID string
|
||||
Et EventType
|
||||
}{
|
||||
SiteID: siteID,
|
||||
Et: et,
|
||||
}
|
||||
mock.lockOnEvent.Lock()
|
||||
mock.calls.OnEvent = append(mock.calls.OnEvent, callInfo)
|
||||
mock.lockOnEvent.Unlock()
|
||||
return mock.OnEventFunc(siteID, et)
|
||||
}
|
||||
|
||||
// OnEventCalls gets all the calls that were made to OnEvent.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedStore.OnEventCalls())
|
||||
func (mock *StoreMock) OnEventCalls() []struct {
|
||||
SiteID string
|
||||
Et EventType
|
||||
} {
|
||||
var calls []struct {
|
||||
SiteID string
|
||||
Et EventType
|
||||
}
|
||||
mock.lockOnEvent.RLock()
|
||||
calls = mock.calls.OnEvent
|
||||
mock.lockOnEvent.RUnlock()
|
||||
return calls
|
||||
}
|
||||
@@ -6,6 +6,20 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestStaticStore_StoreWithoutSites(t *testing.T) {
|
||||
var ks Store = NewStaticKeyStore("key123")
|
||||
enabled, err := ks.Enabled("any")
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, enabled, "on empty store all sites are enabled")
|
||||
assert.NoError(t, ks.OnEvent("test", EvCreate), "static store does nothing OnEvent")
|
||||
|
||||
// empty key
|
||||
ks = NewStaticKeyStore("")
|
||||
key, err := ks.Key("any")
|
||||
assert.Error(t, err, "empty key")
|
||||
assert.Empty(t, key)
|
||||
}
|
||||
|
||||
func TestStaticStore_Get(t *testing.T) {
|
||||
var ks Store = NewStaticStore("key123", []string{"s1", "s2", "s3"},
|
||||
[]string{"123", "xyz"}, "aa@example.com")
|
||||
|
||||
@@ -45,11 +45,13 @@ type Edit struct {
|
||||
|
||||
// PostInfo holds summary for given post url
|
||||
type PostInfo struct {
|
||||
URL string `json:"url"`
|
||||
Count int `json:"count"`
|
||||
ReadOnly bool `json:"read_only,omitempty" bson:"read_only,omitempty"`
|
||||
FirstTS time.Time `json:"first_time,omitempty" bson:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time,omitempty" bson:"last_time,omitempty"`
|
||||
URL string `json:"url,omitempty"` // can be attached to site-wide comments but won't be set then
|
||||
Count int `json:"count"`
|
||||
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" bson:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time" bson:"last_time,omitempty"`
|
||||
}
|
||||
|
||||
// BlockedUser holds id and ts for blocked user
|
||||
@@ -98,6 +100,7 @@ func (c *Comment) SetDeleted(mode DeleteMode) {
|
||||
c.Text = ""
|
||||
c.Orig = ""
|
||||
c.Score = 0
|
||||
c.Controversy = 0
|
||||
c.Votes = map[string]bool{}
|
||||
c.VotedIPs = make(map[string]VotedIPInfo)
|
||||
c.Edit = nil
|
||||
@@ -141,7 +144,7 @@ func (c *Comment) Snippet(limit int) string {
|
||||
if limit <= 0 {
|
||||
limit = snippetLen
|
||||
}
|
||||
cleanText := strings.Replace(c.Text, "\n", " ", -1)
|
||||
cleanText := strings.ReplaceAll(c.Text, "\n", " ")
|
||||
size := len([]rune(cleanText))
|
||||
if size < limit {
|
||||
return cleanText
|
||||
@@ -154,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) + "..."
|
||||
}
|
||||
@@ -176,14 +179,14 @@ func (c *Comment) SanitizeAsURL(inp string) string {
|
||||
|
||||
func (c *Comment) escapeHTMLWithSome(inp string) string {
|
||||
res := template.HTMLEscapeString(inp)
|
||||
res = strings.Replace(res, "&", "&", -1)
|
||||
res = strings.Replace(res, """, "\"", -1)
|
||||
res = strings.Replace(res, "'", "'", -1)
|
||||
res = strings.ReplaceAll(res, "&", "&")
|
||||
res = strings.ReplaceAll(res, """, "\"")
|
||||
res = strings.ReplaceAll(res, "'", "'")
|
||||
return res
|
||||
}
|
||||
|
||||
// SanitizeText used to sanitize any input string
|
||||
// SanitizeText used to sanitize any input string, and removes any HTML tags
|
||||
func (c *Comment) SanitizeText(inp string) string {
|
||||
clean := bluemonday.UGCPolicy().Sanitize(inp)
|
||||
return c.escapeHTMLWithSome(clean)
|
||||
clean := bluemonday.StrictPolicy().Sanitize(inp)
|
||||
return strings.TrimSpace(c.escapeHTMLWithSome(clean))
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestComment_Sanitize(t *testing.T) {
|
||||
},
|
||||
out: Comment{
|
||||
Text: "blah XSS\n\t",
|
||||
User: User{ID: `<a href="http://blah.com">username</a>`, Name: "name <b/>"},
|
||||
User: User{ID: `<a href="http://blah.com">username</a>`, Name: "name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -88,6 +88,14 @@ func TestComment_Sanitize(t *testing.T) {
|
||||
inp: Comment{Text: "blah blah", PostTitle: "<script>alert()</script>something"},
|
||||
out: Comment{Text: "blah blah", PostTitle: "something"},
|
||||
},
|
||||
{
|
||||
inp: Comment{Text: "blah blah", PostTitle: "<a href=\"https://example.com\">test</a>"},
|
||||
out: Comment{Text: "blah blah", PostTitle: "test"},
|
||||
},
|
||||
{
|
||||
inp: Comment{Text: "blah blah", PostTitle: "https://example.com/blah"}, // link is left as-is, but not rendered as <a>
|
||||
out: Comment{Text: "blah blah", PostTitle: "https://example.com/blah"},
|
||||
},
|
||||
{
|
||||
inp: Comment{Text: `<blockquote class="twitter-tweet"><p lang="es" dir="ltr">Silicon iMac Concept<a href="https://t.co/7ga95QxVXn">https://t.co/7ga95QxVXn</a> by <a href="https://twitter.com/marcsheep?ref_src=twsrc%5Etfw">@marcsheep</a> <a href="https://t.co/ULnVpG8w55">pic.twitter.com/ULnVpG8w55</a></p>— Andreas Storm (@avstorm) <a href="https://twitter.com/avstorm/status/1325693387798933504?ref_src=twsrc%5Etfw">November 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`, PostTitle: "Twitter quote"},
|
||||
out: Comment{Text: `<blockquote class="twitter-tweet"><p lang="es" dir="ltr">Silicon iMac Concept<a href="https://t.co/7ga95QxVXn" rel="nofollow">https://t.co/7ga95QxVXn</a> by <a href="https://twitter.com/marcsheep?ref_src=twsrc%5Etfw" rel="nofollow">@marcsheep</a> <a href="https://t.co/ULnVpG8w55" rel="nofollow">pic.twitter.com/ULnVpG8w55</a></p>— Andreas Storm (@avstorm) <a href="https://twitter.com/avstorm/status/1325693387798933504?ref_src=twsrc%5Etfw" rel="nofollow">November 9, 2020</a></blockquote> `, PostTitle: "Twitter quote"},
|
||||
@@ -112,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,
|
||||
@@ -142,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,
|
||||
}
|
||||
@@ -169,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,
|
||||
}
|
||||
@@ -201,7 +209,6 @@ func TestComment_Snippet(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
c := Comment{Text: tt.inp}
|
||||
out := c.Snippet(tt.limit)
|
||||
@@ -237,7 +244,6 @@ func TestComment_sanitizeAsURL(t *testing.T) {
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
c := Comment{}
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
assert.Equal(t, tt.out, c.SanitizeAsURL(tt.inp))
|
||||
@@ -263,12 +269,11 @@ func TestComment_sanitizeText(t *testing.T) {
|
||||
},
|
||||
{
|
||||
"<a href=javascript:alert(document.domain)//>xxx</a>",
|
||||
"xxx</a>",
|
||||
"xxx",
|
||||
},
|
||||
}
|
||||
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
c := Comment{}
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
assert.Equal(t, tt.out, c.SanitizeText(tt.inp))
|
||||
|
||||
@@ -170,7 +170,7 @@ func (b *BoltDB) Find(req FindRequest) (comments []store.Comment, err error) {
|
||||
return e
|
||||
}
|
||||
|
||||
return bucket.ForEach(func(k, v []byte) error {
|
||||
return bucket.ForEach(func(_, v []byte) error {
|
||||
comment := store.Comment{}
|
||||
if e = json.Unmarshal(v, &comment); e != nil {
|
||||
return fmt.Errorf("failed to unmarshal: %w", e)
|
||||
@@ -306,9 +306,8 @@ func (b *BoltDB) Info(req InfoRequest) ([]store.PostInfo, error) {
|
||||
})
|
||||
|
||||
// set read-only from age and manual bucket
|
||||
readOnlyAge := req.ReadOnlyAge
|
||||
info.ReadOnly = readOnlyAge > 0 && !info.FirstTS.IsZero() && info.FirstTS.AddDate(0, 0, readOnlyAge).Before(time.Now())
|
||||
if b.checkFlag(FlagRequest{Locator: req.Locator, Flag: ReadOnly}) {
|
||||
info.ReadOnly = req.ReadOnlyAge > 0 && !info.FirstTS.IsZero() && info.FirstTS.AddDate(0, 0, req.ReadOnlyAge).Before(time.Now())
|
||||
if !info.ReadOnly && b.checkFlag(FlagRequest{Locator: req.Locator, Flag: ReadOnly}) {
|
||||
info.ReadOnly = true
|
||||
}
|
||||
return []store.PostInfo{info}, err
|
||||
@@ -347,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 {
|
||||
@@ -425,11 +424,11 @@ func (b *BoltDB) Close() error {
|
||||
}
|
||||
|
||||
// Last returns up to max last comments for given siteID
|
||||
func (b *BoltDB) lastComments(siteID string, max int, since time.Time) (comments []store.Comment, err error) {
|
||||
func (b *BoltDB) lastComments(siteID string, maximum int, since time.Time) (comments []store.Comment, err error) {
|
||||
comments = []store.Comment{}
|
||||
|
||||
if max > lastLimit || max == 0 {
|
||||
max = lastLimit
|
||||
if maximum > lastLimit || maximum == 0 {
|
||||
maximum = lastLimit
|
||||
}
|
||||
|
||||
bdb, err := b.db(siteID)
|
||||
@@ -467,7 +466,7 @@ func (b *BoltDB) lastComments(siteID string, max int, since time.Time) (comments
|
||||
continue
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
if len(comments) >= max {
|
||||
if len(comments) >= maximum {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -725,7 +724,7 @@ func (b *BoltDB) listDetails(loc store.Locator) (result []UserDetailEntry, err e
|
||||
err = bdb.View(func(tx *bolt.Tx) error {
|
||||
var entry UserDetailEntry
|
||||
bucket := tx.Bucket([]byte(userDetailsBucketName))
|
||||
return bucket.ForEach(func(userID, value []byte) error {
|
||||
return bucket.ForEach(func(_, value []byte) error {
|
||||
if err = json.Unmarshal(value, &entry); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal entry: %w", e)
|
||||
}
|
||||
@@ -866,11 +865,10 @@ func (b *BoltDB) deleteUser(bdb *bolt.DB, siteID, userID string, mode store.Dele
|
||||
// get list of commentID for all user's comment
|
||||
comments := []commentInfo{}
|
||||
for _, postInfo := range posts {
|
||||
postInfo := postInfo
|
||||
err = bdb.View(func(tx *bolt.Tx) error {
|
||||
postsBkt := tx.Bucket([]byte(postsBucketName))
|
||||
postBkt := postsBkt.Bucket([]byte(postInfo.URL))
|
||||
err = postBkt.ForEach(func(postURL []byte, commentVal []byte) error {
|
||||
err = postBkt.ForEach(func(_ []byte, commentVal []byte) error {
|
||||
comment := store.Comment{}
|
||||
if err = json.Unmarshal(commentVal, &comment); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal: %w", err)
|
||||
@@ -959,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)
|
||||
}
|
||||
@@ -974,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)
|
||||
@@ -1029,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(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 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"},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user