diff --git a/.dockerignore b/.dockerignore index 21d954d6..2916ee85 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,24 +1,21 @@ -**/node_modules -static_dist -.env -.idea .git +.github +docs +dist +frontend/node_modules +ftml-capi/target +ftml/target +internal/pgbundle/archive files -db.sqlite3 -postgresql archive -*.env -*.sh -*.cmd -*.bat -static/app.js -static/app.js.map -static/system.js -static/system.js.map -static/highlight.js.css -ftml/target -ftml/pkg -ftml/*.pyd -ftml/*.so -venv -!entrypoint.sh \ No newline at end of file +pgdata +postgres +secrets +backups +logs +update +pwikit +pwikit.exe +pwikit.toml +postgres.lock +docker-compose.override.yaml diff --git a/.env.example b/.env.example deleted file mode 100644 index 7233a867..00000000 --- a/.env.example +++ /dev/null @@ -1,35 +0,0 @@ -# ---- 部署 ---- -COMPOSE_PROJECT_NAME=wikitgo -WEB_PORT=8000 - -# ---- 自动更新 ---- -HOST_PROJECT_DIR=/absolute/path/to/ProjectWikit -UPDATE_REPO=WikitTeam/ProjectWikit -UPDATE_BRANCH=master -UPDATE_POLL_INTERVAL=600 - -# ---- 数据库 ---- -DB_PG_DATABASE=projwikit -DB_PG_USERNAME=admin -DB_PG_PASSWORD=change-me-please - -# ---- Django ---- -SECRET_KEY=change-me-to-a-long-random-string -DEBUG=false - -# ---- 邮件 可选 ---- -# EMAIL_ENGINE=smtp -# EMAIL_HOST=smtp.example.com -# EMAIL_PORT=587 -# EMAIL_USERNAME= -# EMAIL_PASSWORD= -# EMAIL_USE_TLS=true -# EMAIL_DEFAULT_FROM=noreply@example.com - -# ---- 其他 可选 ---- -# MEDIA_HOST= -# ARTICLE_SOURCE_LIMIT=200000 -# ABSOLUTE_MEDIA_UPLOAD_LIMIT=0 -# MEDIA_UPLOAD_LIMIT=0 -# GOOGLE_TAG_ID= -# LOGLEVEL=INFO diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..9366ba31 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.golden -text +*.go text eol=lf +internal/htmlsource/testdata/cases.html -text diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..7412de9d --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,38 @@ +name: Frontend + +on: + pull_request: + branches: [main] + paths: + - "frontend/**" + - "static/**" + - ".github/workflows/frontend.yml" + +permissions: + contents: read + +concurrency: + group: frontend-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: text, types and bundle + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: frontend/yarn.lock + + - name: Install + run: yarn install --frozen-lockfile + + - name: Check text, types and bundle + run: yarn build diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 00000000..b24ed95c --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,89 @@ +name: Go + +on: + pull_request: + branches: [main] + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "internal/migrate/sql/**" + - "internal/i18n/locales/**" + - "internal/**/templates/**" + - "ftml/**" + - "ftml-capi/**" + - ".github/workflows/go.yml" + +permissions: + contents: read + +concurrency: + group: go-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: format and vet + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check formatting + run: | + unformatted=$(gofmt -l cmd internal tools static) + if [ -n "$unformatted" ]; then + echo "These files need gofmt:" + echo "$unformatted" + exit 1 + fi + + - name: Vet + run: go vet -tags nocgo ./... + + migrations: + name: migration declarations + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Every migration declares whether older releases can run on it + run: go test -tags nocgo -run TestEveryMigrationDeclaresCompatibility ./internal/migrate + + test: + name: build and test + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ftml-capi + + - name: Build the ftml library + working-directory: ftml-capi + run: cargo build --release + + - name: Build + run: | + go build ./... + go build -tags nocgo ./... + + - name: Vet with cgo + run: go vet ./... + + - name: Test + run: go test -p 1 ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..26fd879b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,209 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + +jobs: + version: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.pick.outputs.version }} + image: ${{ steps.pick.outputs.image }} + steps: + - id: pick + shell: bash + run: | + echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/pwikit" >> "$GITHUB_OUTPUT" + + build: + needs: version + strategy: + fail-fast: false + matrix: + include: + - platform: linux-amd64 + runner: ubuntu-24.04 + manylinux: quay.io/pypa/manylinux_2_28_x86_64 + - platform: linux-arm64 + runner: ubuntu-24.04-arm + manylinux: quay.io/pypa/manylinux_2_28_aarch64 + - platform: darwin-amd64 + runner: macos-15-intel + - platform: darwin-arm64 + runner: macos-15 + - platform: windows-amd64 + runner: windows-2022 + runs-on: ${{ matrix.runner }} + name: build ${{ matrix.platform }} + env: + VERSION: ${{ needs.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: yarn + cache-dependency-path: frontend/yarn.lock + + - name: Build the page assets + shell: bash + working-directory: frontend + run: | + yarn install --frozen-lockfile + yarn build + + - name: Build the package in manylinux + if: runner.os == 'Linux' + run: | + docker run --rm -v "$PWD:/src" -w /src -e VERSION="$VERSION" ${{ matrix.manylinux }} sh tools/release/linux.sh + sudo chown -R "$(id -u):$(id -g)" dist + + - uses: actions/setup-go@v5 + if: runner.os != 'Linux' + with: + go-version-file: go.mod + + - name: Install the Rust toolchain + if: runner.os == 'macOS' + uses: dtolnay/rust-toolchain@stable + + - name: Install the Rust toolchain for the GNU linker + if: runner.os == 'Windows' + shell: bash + run: | + rustup toolchain install stable-x86_64-pc-windows-gnu --profile minimal + gcc --version + + - name: Build the package + if: runner.os != 'Linux' + shell: bash + run: go run ./tools/release build -version "$VERSION" -out dist -skip-frontend + + - uses: actions/upload-artifact@v4 + with: + name: pwikit-${{ matrix.platform }} + path: | + dist/pwikit-* + dist/libftml_capi-* + if-no-files-found: error + retention-days: 14 + + image: + needs: version + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + name: image ${{ matrix.arch }} + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/${{ matrix.arch }} + build-args: VERSION=${{ needs.version.outputs.version }} + outputs: type=image,name=${{ needs.version.outputs.image }},push-by-digest=true,name-canonical=true,push=true + + - name: Keep the digest + run: | + mkdir -p digests + digest="${{ steps.build.outputs.digest }}" + touch "digests/${digest#sha256:}" + + - uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.arch }} + path: digests/* + retention-days: 1 + + publish: + needs: [version, build, image] + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + env: + VERSION: ${{ needs.version.outputs.version }} + IMAGE: ${{ needs.version.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - uses: actions/download-artifact@v4 + with: + pattern: pwikit-* + path: dist + merge-multiple: true + + - name: Write SHA256SUMS and latest.json + run: | + cp install/install.sh install/install.ps1 dist/ + go run ./tools/release manifest -dir dist -version "$VERSION" -repository "$GITHUB_REPOSITORY" + + - uses: actions/upload-artifact@v4 + with: + name: release-${{ env.VERSION }} + path: dist/* + retention-days: 14 + + - name: Create a draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$VERSION" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --title "$VERSION" \ + --draft \ + --verify-tag \ + --notes "Release notes for $VERSION are written here before the draft is published." + + - uses: actions/download-artifact@v4 + with: + pattern: digest-* + path: digests + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Tag the image + working-directory: digests + run: | + tags="-t $IMAGE:$VERSION" + case "$VERSION" in *-*) ;; *) tags="$tags -t $IMAGE:latest" ;; esac + docker buildx imagetools create $tags $(printf "$IMAGE@sha256:%s " *) diff --git a/.gitignore b/.gitignore index efd6f0e1..9d2ecf1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,15 @@ -db.sqlite3 .idea -*.pyc -files/* -!files/.gitkeep -static_dist -static/app.css.map -static/system.css.map -node_modules +.DS_Store *.log +node_modules +target .env -postgresql -archive *.env -venv -.DS_Store -TODOtions.txt \ No newline at end of file +/files +/archive +/locales +/secrets +postgresql +/internal/pgbundle/archive +/pwikit.toml +/logs diff --git a/Dockerfile b/Dockerfile index 77039d75..9f0736c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,63 +1,44 @@ -# Rust stuff -FROM rust:1.63-buster AS rust_build - -WORKDIR /build - -COPY ftml/Cargo.lock . -COPY ftml/Cargo.toml . - -RUN mkdir src -RUN touch src/lib.rs - -RUN cargo build --release - -COPY ftml . - -RUN cargo build --release - -# JS stuff -FROM node:24-slim AS js_build - -RUN mkdir -p /build/static - -WORKDIR /build/web/js -COPY web/js . -RUN --mount=type=cache,target=/build/.yarn YARN_CACHE_FOLDER=/build/.yarn yarn install && yarn run build - -FROM python:3.13.2 AS python_build - -RUN apt-get update && apt-get install -y tini - -COPY requirements.txt . - -RUN python -m pip install -r requirements.txt -RUN python -m pip install gunicorn - -# Python stuff -FROM python:3.13.2-slim - -WORKDIR /app - +# ProjectWikit container image. It carries no PostgreSQL of its own; the compose +# file in docker/ runs the official one next to it. +# +# docker build -t pwikit --build-arg VERSION=v1.0.0 . +# docker build --target binary --output dist . + +FROM node:20-bookworm AS frontend +WORKDIR /src/frontend +COPY frontend/package.json frontend/yarn.lock ./ +RUN yarn install --frozen-lockfile +COPY frontend/ ./ +COPY static/ /src/static/ +RUN yarn build + +FROM golang:1.26-bookworm AS build +RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal +ENV PATH=/root/.cargo/bin:$PATH +WORKDIR /src +COPY ftml/ ftml/ +COPY ftml-capi/ ftml-capi/ +RUN cd ftml-capi && cargo build --release +COPY go.mod go.sum ./ +RUN go mod download COPY . . - -COPY --from=python_build /usr/local/lib /usr/local/lib -COPY --from=python_build /usr/bin/tini /usr/bin/tini -COPY --from=python_build /usr/local/bin/gunicorn /usr/local/bin/gunicorn - -COPY --from=js_build /build/static/* ./static/ -COPY --from=rust_build /build/target/release/libftml.so ./ftml/ftml.so - -RUN useradd -u 8877 wikitwiki -# This wierd thing extremly speeds up chown -RUN find /app -print0 | xargs -0 -n 100 -P 32 chown wikitwiki:wikitwiki - -USER wikitwiki - -RUN python manage.py collectstatic - -RUN chmod 755 entrypoint.sh - -EXPOSE 8000 - -ENTRYPOINT [ "/usr/bin/tini", "--" ] -CMD [ "/app/entrypoint.sh" ] +COPY --from=frontend /src/static/ static/ +ARG VERSION=v0.0.0-dev +RUN CGO_ENABLED=1 go build -tags assets -trimpath \ + -ldflags "-s -w -X github.com/WikitTeam/ProjectWikit/internal/version.Release=${VERSION}" \ + -o /out/pwikit ./cmd/pwikit \ + && mkdir -p /out/data + +FROM scratch AS binary +COPY --from=build /out/pwikit /pwikit + +FROM gcr.io/distroless/cc-debian12:nonroot +COPY --from=build /out/pwikit /usr/local/bin/pwikit +COPY --from=build --chown=65532:65532 /out/data /data +ENV PWIKIT_DATA_DIR=/data \ + PWIKIT_CONTAINER=1 +WORKDIR /data +VOLUME /data +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/pwikit"] +CMD ["serve", "-listen", ":8080"] diff --git a/README.md b/README.md index 0ac0d3d7..3c7d35b9 100644 --- a/README.md +++ b/README.md @@ -1,230 +1,20 @@ -
-

ProjectWikit

-

旨在将Wikidot站点完整地迁移到兼容Wikidot结构的ProjectWikit,并支持基于FTML的Wikidot语法。

-
+# ProjectWikit -> [!NOTE] -> This project, ProjectWikit, originated as a fork of the [RuFoundation Engine](https://github.com/SCPRu/RuFoundation). However, it has since undergone numerous modifications, structural changes, and feature enhancements, becoming an independent project, no longer tracking or following updates from the original RuFoundation codebase, and no longer maintaining compatibility with it. -> -> As a result, ProjectWikit is fully maintained by its own development team. If you encounter any issues or have suggestions, please report them directly to the WikitTeam rather than the original RuFoundation Team. -> -> ----- -> ProjectWikit最初是 [RuFoundation引擎](https://github.com/SCPRu/RuFoundation) 的分支。然而,自此之后,它已历经了大量的修改、结构调整和功能扩展,并已成为一个独立的项目,不再追踪或跟随原始的RuFoundation代码库的更新,亦不再保持对其的兼容。 -> -> 因此,ProjectWikit完全由其自身的开发团队维护。如果您遇到任何问题或有任何建议,请直接向负责人Kakushi或WikitTeam报告,而不是向原始的RuFoundation团队反馈。 +A Wikidot-compatible wiki engine and migration target. -## 环境要求 -以下是测试时的环境,你可能会与之有出入 -- Windows 10 -- PostgreSQL 17.2 -- NodeJS v17.3.0 -- Python 3.13.2 -- Rust 1.63 +ProjectWikit began as a fork of [RuFoundation](https://github.com/scpru/rufoundation), the wiki software written for the Russian SCP branch. The current implementation is a ground-up Go rewrite, with the core implementation developed independently. But the project owes its existence and much of its early direction to that work. -以下为 PostgreSQL 的默认配置,你可以通过给定的环境变量进行修改: -| 名称 | 变量值 | 变量名 | -| :-------- | :-------------- | :------------------ | -| 用户名 | `admin` | `POSTGRES_USER` | -| 密码 | `wikitpassword` | `POSTGRES_PASSWORD` | -| 数据库名 | `projwikit` | `POSTGRES_DB` | -| 数据库主机 | `localhost` | `DB_PG_HOST` | -| 数据库端口 | `5432` | `DB_PG_PORT` | +Wikidot syntax parsing uses [FTML-WIKIT](https://github.com/WikitTeam/ProjectWikit/tree/major/ftml), a hard fork of Wikijump's [FTML](https://github.com/scpwiki/ftml). Upstream FTML focuses on the subset of Wikidot syntax considered well-formed, and as a result, some syntax constructs not remain compatible. The fork is maintained separately and is not expected to merge back. Our goal is to achieve exact compatibility with Wikidot's output. -## 快速部署 -> [!TIP] -> 在开始部署前,请**复制** `.env.example` 为 `.env`。数据库名称/密码、对外端口、栈名等所有部署配置都集中在 `.env` 里,无需再改 `docker-compose.yaml`。 +## What it does -`.env` 示例: +- Imports Wikidot sites (users, pages, history, votes, forums) from backups +- Renders Wikidot syntax with output matched against real Wikidot HTML +- Wiki Farm: one instance serves many wikis +- Wikidot account claiming with external verification -```dotenv -# ---- 部署 ---- -COMPOSE_PROJECT_NAME=wikitgo -WEB_PORT=8000 +## Install -# ---- 自动更新 ---- -HOST_PROJECT_DIR=/opt/ProjectWikit -UPDATE_REPO=WikitTeam/ProjectWikit -UPDATE_BRANCH=master -UPDATE_POLL_INTERVAL=600 +## Documentation -# ---- 数据库 ---- -DB_PG_DATABASE=projwikit -DB_PG_USERNAME=admin -DB_PG_PASSWORD=改成你的密码 -# ---- Django ---- -SECRET_KEY=改成一段足够长的随机字符串 -DEBUG=false -``` - -> [!IMPORTANT] -> 如需使用「后台自动更新」,`HOST_PROJECT_DIR` 必须设为本项目在**宿主机**上的绝对路径(在项目根目录执行 `pwd` 查看,例如 `/opt/ProjectWikit`),否则 updater 容器无法拉取代码与重建。 - -
- 使用 Docker 快速部署(推荐) - -### 1.Docker环境 -- Docker 28.4.0 -- Docker Compose 2.39.4 - -### 2.Docker 部署 - - **【STEP1】** 启动项目,运行 `docker compose up` - - - **【STEP2】** 在数据库中创建用户、网站并填充初始数据,请先启动项目,然后使用如下命令: - - `docker exec -it wikitgo-web-1 python manage.py createsite -s wikit-wiki -d 网站域名(本地填写localhost) -t "网站标题" -H "网站副标题"` - - `docker exec -it wikitgo-web-1 python manage.py migrate` - - `docker exec -it wikitgo-web-1 python manage.py createsuperuser` - - `docker exec -it wikitgo-web-1 seed` - - - **【OTHER】** 对于无法使用上述指令的场合(因权限不足\迁移未完成等造成的问题): - - `docker compose down` 若已运行容器,则先关闭容器 - - `sudo chmod -R 777 ./files` - - `docker compose up -d` - - `docker exec -it wikitgo-web-1 python manage.py migrate` - - `docker exec -it wikitgo-web-1 python manage.py createsite -s wikit-wiki -d 网站域名 -t "网站标题" -H "网站副标题"` - - `docker exec -it wikitgo-web-1 python manage.py createsuperuser` - - `docker exec -it wikitgo-web-1 python manage.py seed` - -### 3.其他操作 - - 从备份完整迁移Wikidot网站:详见下方 [从备份迁移数据](#从备份迁移数据) 章节。 - - - 完全删除所有数据: - - `docker compose down` - - `rm -rf ./files ./archive ./postgresql` - - - 要更新正在运行的应用: - - `docker compose up -d --no-deps --build web` -
- -
- 使用 Python 快速部署 - -### 1.Python环境 -- Python 3.13.2 - -### 2.Python 部署 - - **【STEP1】** 安装配置环境 - - 定位到 `web/js` 目录,执行 `yarn install` - - 在项目根目录下,运行 `pip install -r requirements.txt` - - - **【STEP2】** 启动配置 - - `python manage.py migrate` - - `python manage.py runserver --watch` - - - **【STEP3】** 创建管理员账户 - - 运行 `python manage.py createsuperuser --username Admin --email "" --skip-checks` - - 根据终端提示完成操作 - - - **【STEP4】** 数据库初始数据填充 - - 创建以下基础对象: - - 网站记录(用于本地主机) - - 部分重要的页面(如 `nav:top` 或 `nav:side`) - - 或者,通过运行以下命令来配置这些基本结构: - - `python manage.py createsite -s wikit-wiki -d localhost -t "网站标题" -H "网站副标题"` - - `python manage.py seed` - -
- -## 自动更新 - -拥有「管理系统更新」权限的用户可在后台一键把站点更新到 GitHub 上的最新发布版本,无需登录服务器手动操作。 - -### 功能 -- 后台自动定时检查 GitHub 仓库的最新 release(默认每 10 分钟一次),有新版本时在后台提示。 -- 展示当前版本、最新版本号与该版本的更新说明。 -- 一键更新:自动拉取对应版本代码 → 重建并重启 `web` 服务 → 更新完成后自动清理无用的镜像与旧构建缓存。 -- 更新过程实时显示进度与日志;更新失败会给出原因,且不会覆盖服务器上任何用户数据或本地改动。 -- 权限受「管理系统更新」(`manage_updates`)控制,可在后台「角色」中授予指定角色。 - -### 用法 -1. 部署前在 `.env` 中正确设置 `HOST_PROJECT_DIR`(见上方「快速部署」)。 -2. 正常 `docker compose up -d` 启动,`updater` 服务会随之一起运行,之后全程自动,无需额外配置。 -3. 在 GitHub 仓库的 `master` 分支上发布(release)新版本。 -4. 用有权限的账户访问 **`/-/admin/update`**,看到「有可用更新」后点击「更新到最新版本」,等待进度完成即可。 - -> [!NOTE] -> 更新只会在服务器代码可干净切换时进行。若服务器上对被 git 跟踪的文件做过本地改动,更新会中止并提示,以免覆盖你的修改——请把服务器特有配置都放在 `.env`(不被 git 跟踪)中。 - -## 站点设置与主题 - -站点的各项参数都在后台 **`/-/admin` → 站点** 面板中设置,改动即时生效,无需改动代码或重新部署。 - -### 站点面板字段 -| 字段 | 说明 | -| --- | --- | -| 缩写 | 站点内部标识符 | -| 标题 / 副标题 | wiki 显示的名称与其下方的小字 | -| 图标 | 站点图标 | -| 文章域名(主域名) | 访问 wiki 页面所用的主域名 | -| 文件域名 | 提供用户上传文件/附件的域名,可与主域名不同以做隔离 | -| 主页名称 | 访问站点根路径(`/`)时展示哪个页面,默认 `main` | -| 站点主题 | 选择当前启用的主题(见下方「主题」) | -| 评分系统 | 可选 默认 / 禁用 / 点赞·点踩 / 星级评分;默认即点赞·点踩(uv/dv)制 | -| 用户是否可以创建标签 | `默认` / `禁止` / `允许` | - -### 主页设定 -在「站点」面板的 **主页名称** 填入页面名即可把该页面设为首页(默认 `main`)。例如填 `start`,则访问根路径会显示 `start` 这篇文章。 - -### 主题 -主题允许你在后台直接编辑站点 CSS,无需修改仓库里的文件。 - -- 在后台 **`/-/admin` → 主题** 中可以创建多个主题,每个主题取一个名字,并二选一:**内联 CSS** 或 **外部链接**。 -- 在「站点」面板的 **站点主题** 下拉中选择要启用的那一个即可切换全站外观。 -- 未选择任何主题时,回退到项目自带的默认样式。 - -> [!TIP] -> 首次部署后系统会自动生成一个名为「默认主题」的主题,其内容即当前的默认样式,可在它基础上修改,或复制一份另存为新主题。 - -## 从备份迁移数据 -将 [wikitCLI](https://github.com/kakushi-w/wikit) 生成的 Wikidot 备份导入到已部署的站点,包括页面、历史修订、附件、标签、页面评分、父子关系以及论坛。 - -> [!TIP] -> 迁移前请确认目标站点已创建(即已执行过 `createsite`)。 - -### 1.准备备份 -将备份中的 `_users`、`files`、`forum`、`meta`、`pages` 文件夹一并放入 `./archive` 目录。 - -### 2.执行迁移 -基本命令为 `docker exec -it wikitgo-web-1 python manage.py seed -a <备份路径>`,通过以下参数控制迁移范围与行为。 - -| 参数 | 作用 | -| :-- | :-- | -| `-a, --archive <路径>` | 指定备份归档路径,启用迁移模式 | -| `-s, --scope {all,pages,forum}` | 迁移范围:`all` 全部、`pages` 仅页面(含文件 / 标签 / 评分 / 父页面)、`forum` 仅论坛。默认 `all` | -| `-t, --force-tags` | 强制迁移标签:当站点设置为“禁止用户创建标签”时,仍然导入备份中的标签 | -| `--no-votes` | 跳过页面评分的迁移 | -| `--update-existing` | 对库中已存在的文章也重新同步标签与评分(默认已存在文章整体跳过)。仅补充缺失的评分,不会覆盖已有投票 | - -### 3.常见场景 - - **完整迁移整个站点**(页面在前、论坛在后,自动按顺序处理): - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive` - - - **只迁移页面**(例如先导入内容,稍后再单独处理论坛): - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive -s pages` - - - **只迁移论坛**(页面此前已经导入过): - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive -s forum` - - - **标签没有被导入**(站点默认禁止创建标签时会出现):追加 `-t` 强制导入标签 - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive -t` - - - **不想导入历史评分**:追加 `--no-votes` - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive --no-votes` - - - **页面已经导入过,只想补上标签和评分**:追加 `--update-existing` - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive -s pages -t --update-existing` - - - **附件 / 图片显示 not found(物理文件缺失)**:重跑一次页面迁移即可,脚本会检测缺失并从备份补拷 - - `docker exec -it wikitgo-web-1 python manage.py seed -a ./archive -s pages` - - - **迁移后搜索不到文章**(搜索索引未建):运行 `initsearch` 重建全站搜索索引 - - `docker exec -it wikitgo-web-1 python manage.py initsearch` - -> [!NOTE] -> `forum` 依赖页面数据来关联文章的评论区,因此单独迁移论坛前,请确保对应页面已经迁移完成;使用 `all` 时无需担心,脚本会先迁移页面再迁移论坛。 - -> [!NOTE] -> 迁移脚本不会自动建立搜索索引(索引仅在编辑文章时更新)。因此**每次迁移完成后都应运行一次 `initsearch`**,否则新导入的文章无法被站内搜索检索到。 - -> [!NOTE] -> 文件迁移是增量安全的:不会清空已有的媒体目录,重复运行只会补拷缺失的物理文件,不会删除或重复已存在的附件。因此附件出现 not found 时,直接重跑页面迁移即可恢复(前提是备份中的 `files/` 目录仍然完整)。 diff --git a/REVISIONS.md b/REVISIONS.md deleted file mode 100644 index 541c0265..00000000 --- a/REVISIONS.md +++ /dev/null @@ -1,213 +0,0 @@ -# Article revision format - -Our revisions are currently encoded using free-form JSONB. - -That might be changed in the future at some point; to keep some sane documentation for now, this file is used. - -Each revision is an instance of `ArticleLogEntry`. Thus, all of them have the following shared fields: - -- Article ID -- User ID -- Revision type -- Revision metadata (see below) -- Date -- Comment -- Revision index (within the article) - -Supported revision types currently: - -- `LogEntryType.Source`: source code change -- `LogEntryType.Title`: title change -- `LogEntryType.Name`: slug (URL, pageId) change -- `LogEntryType.Tags`: tag list change -- `LogEntryType.New`: formal revision that marks the creation of a page -- `LogEntryType.Parent`: parent page change -- `LogEntryType.FileAdded`: file added -- `LogEntryType.FileDeleted`: file deleted -- `LogEntryType.FileRenamed`: file renamed -- `LogEntryType.VotesDeleted`: votes deleted -- `LogEntryType.Wikidot`: wikidot revision; does nothing, cannot be reverted, contains comments (added for historical review) -- `LogEntryType.Revert`: revert revision - -Each of them has special format of metadata field; these are detailed below. - -## `LogEntryType.Source` - -```javascript -{ - "version_id": int /* ArticleVersion ID */ -} -``` - -## `LogEntryType.Title` - -```javascript -{ - "title": string, - "prev_title": string -} -``` - -## `LogEntryType.Name` - -```javascript -{ - "name": string, /* full slug, including category */ - "prev_name": string /* full slug, including category */ -} -``` - -## `LogEntryType.Tags` - -```javascript -{ - "added_tags": [{ - "id": int, /* Tag ID */ - "name": string /* full slug, including category; used only for visuals */ - }], - "removed_tags": [{ - "id": int, /* Tag ID */ - "name": string /* full slug, including category; used only for visuals */ - }] -} -``` - -## `LogEntryType.New` - -These values are _usually_ not used, just present tracking. - -This is because reverting revisions is done by undoing the change, and you can't undo the "new" revision. - -```javascript -{ - "version_id": int, /* ArticleVersion ID */ - "title": string /* initial title of article */ -} -``` - -## `LogEntryType.Parent` - -```javascript -{ - "parent": string, /* full slug, including category; used only for visuals */ - "prev_parent": string, /* full slug, including category; used only for visuals */ - "parent_id": int, /* Article ID */ - "prev_parent_id": int /* Article ID */ -} -``` - -## `LogEntryType.FileAdded` - -```javascript -{ - "id": int, /* File ID */ - "name": string -} -``` - -## `LogEntryType.FileDeleted` - -```javascript -{ - "id": int, /* File ID */ - "name": string -} -``` - -## `LogEntryType.FileRenamed` - -```javascript -{ - "id": int, /* File ID */ - "name": string, - "prev_name": string -} -``` - -## `LogEntryType.VotesDeleted` - -The system stores votes that were present at the moment of deletion. - -```javascript -{ - "rating_mode": string, /* Settings.RatingMode enum */ - "rating": int | float, /* sum or average, depending on rating mode */ - "votes_count": int, - "popularity": float, - "votes": [{ - "user_id": int, /* User ID */ - "vote": int | float, /* 1 or -1, or 0..5 float, depending on rating mode */ - "visual_group_id": int | null, /* VisualUserGroup ID */ - "date": string /* ISO 8601 datetime */ - }] -} -``` - -## `LogEntryType.Revert` - -Note that metadata fields here are optional depending on specific revert subtypes. - -```javascript -{ - "subtypes": [string], /* LogEntryType enum */ - "rev_number": int, /* revision index that was reverted to */ - /* present only for file-related subtypes */ - "files": { - /* present if subtype has FileAdded */ - "added": [{ - "id": int, /* File ID */ - "name": string - }], - /* present if subtype has FileDeleted */ - "deleted": [{ - "id": int, /* File ID */ - "name": string - }], - /* present if subtype has FileRenamed */ - "renamed": [{ - "id": int, /* File ID */ - "name": string, - "prev_name": string - }] - }, - /* present if subtype has Tags */ - "tags": { - "added": [int], /* Tag ID */ - "removed": [int] /* Tag ID */ - }, - /* present if subtype has Source */ - "source": { - "version_id": int /* ArticleVersion ID */ - }, - /* present if subtype has Title */ - "title": { - "title": string, - "prev_title": string - }, - /* present if subtype has Name */ - "name": { - "name": string, /* full slug, including category */ - "prev_name": string /* full slug, including category */ - }, - /* present if subtype has Parent */ - "parent": { - "parent": string, /* full slug, including category; used only for visuals */ - "prev_parent": string, /* full slug, including category; used only for visuals */ - "parent_id": int, /* Article ID */ - "prev_parent_id": int /* Article ID */ - }, - /* present if subtype has Votes */ - "votes": { - "rating_mode": string, /* Settings.RatingMode enum */ - "rating": int | float, /* sum or average, depending on rating mode */ - "votes_count": int, - "popularity": float, - "votes": [{ - "user_id": int, /* User ID */ - "vote": int | float, /* 1 or -1, or 0..5 float, depending on rating mode */ - "visual_group_id": int | null, /* VisualUserGroup ID */ - "date": string /* ISO 8601 datetime */ - }] - } -} -``` \ No newline at end of file diff --git a/cmd/pwikit/admin.go b/cmd/pwikit/admin.go new file mode 100644 index 00000000..42aa7e2e --- /dev/null +++ b/cmd/pwikit/admin.go @@ -0,0 +1,237 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "golang.org/x/term" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/password" + "github.com/WikitTeam/ProjectWikit/internal/wikidot" +) + +func adminCommand(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + if sub != "create" && sub != "grant" && sub != "revoke" { + fmt.Fprint(os.Stderr, `Usage: pwikit admin -name [options] + + create take over an imported account or make a new one, and give it every right + grant give every right to an account that already exists + revoke take every right back from an account, leaving the account itself alone + +Import an archive before creating the first administrator. An account the +archive brought in is taken over in place, so the pages and posts it wrote stay +with the account you sign in as. + +Options: + -name name to sign in as; spaces and other scripts are allowed + -password-stdin read the password from standard input instead of asking + -yes create a new account without asking +`) + return errors.New("unknown admin subcommand") + } + fs := flag.NewFlagSet("admin "+sub, flag.ContinueOnError) + name := fs.String("name", "", "name to sign in as") + fromStdin := fs.Bool("password-stdin", false, "read the password from standard input instead of asking") + yes := fs.Bool("yes", false, "create a new account without asking") + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + raw, canonical, err := adminName(*name) + if err != nil { + return err + } + + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + switch sub { + case "grant": + return grantAdmin(ctx, conn, raw, canonical) + case "revoke": + return revokeAdmin(ctx, conn, raw, canonical) + } + return createAdmin(ctx, conn, raw, canonical, *fromStdin, *yes) +} + +func adminName(given string) (raw, canonical string, err error) { + raw = wikidot.NormalizeDisplayName(given) + if wikidot.ValidateDisplayName(raw) != wikidot.DisplayNameOK { + return "", "", errors.New("-name is empty, too long, or starts with a mark") + } + canonical = wikidot.CanonicalizeUsername(raw) + if canonical == "" { + return "", "", fmt.Errorf("name %q holds no letters or digits", raw) + } + if wikidot.ReservedUsername(canonical) { + return "", "", fmt.Errorf("name %q is reserved", canonical) + } + return raw, canonical, nil +} + +func createAdmin(ctx context.Context, conn *db.DB, raw, canonical string, fromStdin, yes bool) error { + found, hash, err := conn.UserToClaim(ctx, canonical, strings.ToLower(raw)) + switch { + case err != nil && !errors.Is(err, db.ErrNotFound): + return err + case err == nil && password.IsUsable(hash): + return fmt.Errorf("%s (#%d) already has a password; run `pwikit admin grant -name %q` instead", + found.Username, found.ID, raw) + } + + // One reader for the whole command. A second one over os.Stdin would find + // the first had already buffered past the line it read. + in := bufio.NewReader(os.Stdin) + if found == nil { + if err := confirmNewAccount(in, canonical, yes); err != nil { + return err + } + } + + plain, err := readPassword(in, fromStdin) + if err != nil { + return err + } + if err := password.Validate(plain, password.Attributes{Username: canonical, DisplayName: raw}); err != nil { + return err + } + encoded, err := password.Hash(plain) + if err != nil { + return err + } + + var display *string + if raw != canonical { + display = &raw + } + if found != nil { + if err := conn.ActivateUser(ctx, found.ID, canonical, display, encoded); err != nil { + return err + } + if err := conn.SetSuperuser(ctx, found.ID, true); err != nil { + return err + } + fmt.Printf("took over %s (#%d), imported as %q\n", canonical, found.ID, found.WikidotUsername) + return nil + } + + id, err := conn.CreateUser(ctx, canonical, raw, encoded, true, time.Now().UTC()) + if err != nil { + return err + } + if err := conn.SetSuperuser(ctx, id, true); err != nil { + return err + } + fmt.Printf("created %s (#%d)\n", canonical, id) + return nil +} + +func confirmNewAccount(in *bufio.Reader, canonical string, yes bool) error { + if yes { + return nil + } + fmt.Fprintf(os.Stderr, `No account is named %q. + +A new one will be created, holding nothing. If you meant to sign in as an +account an archive brought in, stop here: import the archive first, and spell +the name the way that site spelled it. + +Create a new account? [y/N] `, canonical) + answer, err := in.ReadString('\n') + if err != nil { + return err + } + if strings.ToLower(strings.TrimSpace(answer)) != "y" { + return errors.New("cancelled") + } + return nil +} + +func grantAdmin(ctx context.Context, conn *db.DB, raw, canonical string) error { + found, _, err := conn.UserToClaim(ctx, canonical, strings.ToLower(raw)) + if errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("no account is named %q", canonical) + } + if err != nil { + return err + } + if found.IsSuperuser { + fmt.Printf("%s (#%d) already has every right\n", found.Username, found.ID) + return nil + } + if err := conn.SetSuperuser(ctx, found.ID, true); err != nil { + return err + } + fmt.Printf("granted every right to %s (#%d)\n", found.Username, found.ID) + return nil +} + +func revokeAdmin(ctx context.Context, conn *db.DB, raw, canonical string) error { + found, _, err := conn.UserToClaim(ctx, canonical, strings.ToLower(raw)) + if errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("no account is named %q", canonical) + } + if err != nil { + return err + } + if !found.IsSuperuser { + fmt.Printf("%s (#%d) does not have every right\n", found.Username, found.ID) + return nil + } + left, err := conn.SuperuserCount(ctx) + if err != nil { + return err + } + if err := conn.SetSuperuser(ctx, found.ID, false); err != nil { + return err + } + fmt.Printf("took every right back from %s (#%d)\n", found.Username, found.ID) + if left <= 1 { + fmt.Fprintln(os.Stderr, "that was the last one; nobody can reach the admin pages until `pwikit admin grant` runs") + } + return nil +} + +// A password typed at a prompt would otherwise stay on screen and in the +// scrollback of whoever runs this. +func readPassword(in *bufio.Reader, fromStdin bool) (string, error) { + if fromStdin || !term.IsTerminal(int(os.Stdin.Fd())) { + line, err := in.ReadString('\n') + if err != nil && line == "" { + return "", err + } + return strings.TrimRight(line, "\r\n"), nil + } + fmt.Fprint(os.Stderr, "Password: ") + typed, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return string(typed), nil +} diff --git a/cmd/pwikit/articles.go b/cmd/pwikit/articles.go new file mode 100644 index 00000000..c5c7fc19 --- /dev/null +++ b/cmd/pwikit/articles.go @@ -0,0 +1,228 @@ +package main + +import ( + "io/fs" + "log/slog" + "net/http" + "os" + "strconv" + + "github.com/WikitTeam/ProjectWikit/internal/account" + "github.com/WikitTeam/ProjectWikit/internal/admin" + "github.com/WikitTeam/ProjectWikit/internal/articlepage" + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/lang" + "github.com/WikitTeam/ProjectWikit/internal/localitem" + "github.com/WikitTeam/ProjectWikit/internal/mail" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/proxyheader" + "github.com/WikitTeam/ProjectWikit/internal/roles" + "github.com/WikitTeam/ProjectWikit/internal/session" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/static" + "github.com/WikitTeam/ProjectWikit/internal/token" + "github.com/WikitTeam/ProjectWikit/internal/update" + "github.com/WikitTeam/ProjectWikit/internal/userpage" + "github.com/WikitTeam/ProjectWikit/internal/webapi" +) + +type pageStack struct { + articles http.Handler + code http.Handler + html http.Handler + theme http.Handler + moduleAPI http.Handler + preview http.Handler + profile http.Handler + profileForm http.Handler + reactivePages http.Handler + notifyAPI http.Handler + subscribeAPI http.Handler + messageAPI http.Handler + userAPI http.Handler + adminAPI http.Handler + login http.Handler + logout http.Handler + signup http.Handler + accept http.Handler + reset http.Handler + tickets http.Handler + emailLinks http.Handler + settings http.Handler + adminPages http.Handler + allArticles http.Handler + favesAPI http.Handler + ownRowsAPI http.Handler + articleAPI http.Handler + fileAPI http.Handler + unresolved http.Handler + close func() +} + +type limits struct { + soft int64 + hard int64 +} + +func newPageStack(conn *db.DB, p *paths.Paths, assets fs.FS, next http.Handler, trust *proxyheader.Trust, size limits, sidecar, secret string, cfg config.File, board *update.Board, log *slog.Logger) (*pageStack, error) { + engine, closeEngine, err := newRenderer(sidecar) + if err != nil { + return nil, err + } + bundle, err := i18n.Load(p.Locales()) + if err != nil { + closeEngine() + return nil, err + } + icons := roles.FileIcons(p.Files()) + board.Bundle = bundle + + pages := articlepage.New(articlepage.Deps{ + DB: conn, + Engine: engine, + Bundle: bundle, + Icons: icons, + Assets: static.NewAssets(assets), + GoogleTagID: envOr(envGoogleTag, cfg.Analytics.GoogleTagID), + Log: log, + }) + items := localitem.Deps{DB: conn, Engine: engine, Bundle: bundle, Icons: icons, Log: log} + api := webapi.Deps{DB: conn, Trust: trust, Engine: engine, Bundle: bundle, Icons: icons, + Tokens: token.Generator{Secret: secret}, + Files: p.Files(), SoftLimit: size.soft, HardLimit: size.hard, Log: log} + + profiles := userpage.Deps{ + DB: conn, Engine: engine, Bundle: bundle, Icons: icons, + Assets: static.NewAssets(assets), Files: p.Files(), Log: log, + } + + stack := &pageStack{ + articles: pages, + code: localitem.NewCode(items), + html: localitem.NewHTML(items), + theme: localitem.NewTheme(items), + moduleAPI: webapi.New(api, next), + preview: webapi.NewPreview(api), + articleAPI: webapi.NewArticles(api, next), + allArticles: webapi.NewAllArticles(api, next), + fileAPI: webapi.NewFileItems(api, next), + profile: userpage.New(profiles), + profileForm: userpage.NewEdit(profiles), + reactivePages: userpage.NewReactive(profiles), + notifyAPI: webapi.NewNotifications(api, next), + subscribeAPI: webapi.NewSubscriptions(api, next), + messageAPI: webapi.NewMessages(api, next), + userAPI: webapi.NewUsers(api, next), + adminAPI: webapi.NewAdmin(api, next), + login: next, + logout: next, + signup: next, + accept: next, + reset: next, + tickets: next, + emailLinks: next, + settings: next, + adminPages: next, + favesAPI: webapi.NewFavourites(api, next), + ownRowsAPI: webapi.NewOwnRows(api, next), + close: closeEngine, + unresolved: site.NewUnresolved(bundle, static.NewAssets(assets)), + } + store := session.New(secret) + accounts := account.Deps{ + DB: conn, Sessions: store, Engine: engine, Icons: icons, Bundle: bundle, + Tokens: token.Generator{Secret: secret}, + Verifier: account.NewVerifier(), + Mail: mail.New(mailConfig(cfg.Mail)), + Assets: static.NewAssets(assets), Trust: trust, Log: log, + } + stack.login = account.NewLogin(accounts) + stack.logout = account.NewLogout(accounts) + stack.signup = account.NewSignup(accounts) + stack.accept = account.NewAccept(accounts) + stack.reset = account.NewReset(accounts) + stack.tickets = account.NewTickets(accounts) + stack.emailLinks = account.NewEmail(accounts) + stack.settings = account.NewSettings(accounts) + + adminPages, err := admin.New(admin.Deps{ + DB: conn, Bundle: bundle, Assets: static.NewAssets(assets), Files: p.Files(), + Tokens: token.Generator{Secret: secret}, Articles: stack.articleAPI, + Mail: mail.New(mailConfig(cfg.Mail)), Trust: trust, Updates: board, Log: log, + }, next) + if err != nil { + return nil, err + } + stack.adminPages = adminPages + + resolver := auth.NewResolver(store, conn, conn, log) + negotiate := lang.Middleware(bundle) + resolved := func(h http.Handler) http.Handler { return resolver.Middleware(negotiate(h)) } + stack.login = resolved(stack.login) + stack.logout = resolved(stack.logout) + stack.signup = resolved(stack.signup) + stack.accept = resolved(stack.accept) + stack.reset = resolved(stack.reset) + stack.tickets = resolved(stack.tickets) + stack.emailLinks = resolved(stack.emailLinks) + stack.settings = resolved(stack.settings) + stack.adminPages = resolved(stack.adminPages) + stack.articleAPI = resolved(stack.articleAPI) + stack.allArticles = resolved(stack.allArticles) + stack.fileAPI = resolved(stack.fileAPI) + stack.code = resolved(stack.code) + stack.html = resolved(stack.html) + stack.theme = resolved(stack.theme) + stack.moduleAPI = resolved(stack.moduleAPI) + stack.preview = resolved(stack.preview) + stack.profile = resolved(stack.profile) + stack.profileForm = resolved(stack.profileForm) + stack.reactivePages = resolved(stack.reactivePages) + stack.notifyAPI = resolved(stack.notifyAPI) + stack.subscribeAPI = resolved(stack.subscribeAPI) + stack.messageAPI = resolved(stack.messageAPI) + stack.userAPI = resolved(stack.userAPI) + stack.adminAPI = resolved(stack.adminAPI) + stack.favesAPI = resolved(stack.favesAPI) + stack.ownRowsAPI = resolved(stack.ownRowsAPI) + stack.articles = resolved(stack.articles) + return stack, nil +} + +func mailConfig(file config.Mail) mail.Config { + if envOr(envMailEngine, file.Engine) == config.EngineConsole { + return mail.Config{} + } + filePort := "" + if file.Port > 0 { + filePort = strconv.Itoa(file.Port) + } + port := envOr(envMailPort, filePort) + if port == "" { + port = defaultMailPort + } + return mail.Config{ + Host: envOr(envMailHost, file.Host), + Port: port, + Username: envOr(envMailUser, file.Username), + Password: envOr(envMailPassword, file.Password), + UseTLS: switchSetting(envMailTLS, file.UseTLS) || port == implicitTLSPort, + Implicit: switchSetting(envMailImplicit, file.ImplicitTLS) || port == implicitTLSPort, + From: envOr(envMailFrom, file.From), + } +} + +func switchSetting(env string, file *bool) bool { + if value := os.Getenv(env); value != "" { + return value == "true" + } + return file != nil && *file +} + +const ( + implicitTLSPort = "465" + defaultMailPort = "587" +) diff --git a/cmd/pwikit/backup.go b/cmd/pwikit/backup.go new file mode 100644 index 00000000..277ea588 --- /dev/null +++ b/cmd/pwikit/backup.go @@ -0,0 +1,259 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/backup" + "github.com/WikitTeam/ProjectWikit/internal/paths" +) + +func backupCommand(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "create", "list", "verify", "restore": + default: + fmt.Fprint(os.Stderr, `Usage: pwikit backup [options] + + create write a backup of the database and the uploaded files + list show the backups in the backup directory + verify read a backup through and report whether it is sound + restore put a backup back, replacing what is there now + +Options: + -output where create writes; defaults to a timestamped name in backups/ + -no-files leave the uploaded files out of a backup, or alone on restore + -site back up one site on its own instead of the whole instance + -keep-passwords carry the sign-in passwords into a single site backup + -dir directory list reads; defaults to backups/ + -force let restore replace a database that already holds data + -no-safety-backup let restore skip the backup it takes of the current state +`) + return errors.New("unknown backup subcommand") + } + + fs := flag.NewFlagSet("backup "+sub, flag.ContinueOnError) + output := fs.String("output", "", "where create writes the backup") + noFiles := fs.Bool("no-files", false, "leave the uploaded files out of a backup, or alone on restore") + site := fs.String("site", "", "back up one site on its own instead of the whole instance") + keepPasswords := fs.Bool("keep-passwords", false, "carry the sign-in passwords into a single site backup") + dir := fs.String("dir", "", "directory list reads") + force := fs.Bool("force", false, "let restore replace a database that already holds data") + noSafety := fs.Bool("no-safety-backup", false, "skip the backup restore takes of the current state") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + loose, err := parseMixed(fs, args[1:]) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + p, err := paths.New(*dataDir) + if err != nil { + return err + } + if sub == "list" { + where := *dir + if where == "" { + where = p.Backups() + } + return listBackups(where) + } + if sub == "verify" { + if len(loose) != 1 { + return errors.New("verify needs one backup file") + } + return verifyBackup(loose[0]) + } + dsn, release, err := resolveDatabase(context.Background(), *database, *dataDir) + if err != nil { + return err + } + defer release() + + files := p.Files() + if *noFiles { + files = "" + } + if sub == "create" { + return createBackup(dsn, files, *output, p.Backups(), *site, *keepPasswords) + } + if len(loose) != 1 { + return errors.New("restore needs one backup file") + } + return restoreBackup(loose[0], dsn, files, p.Backups(), *force, *noSafety) +} + +// A file name reads naturally before the flags, and the flag package stops at +// the first thing that is not one, so the two are separated here instead. +func parseMixed(fs *flag.FlagSet, args []string) ([]string, error) { + var loose []string + for { + if err := fs.Parse(args); err != nil { + return nil, err + } + if fs.NArg() == 0 { + return loose, nil + } + loose = append(loose, fs.Arg(0)) + args = fs.Args()[1:] + } +} + +func createBackup(dsn, files, output, backups, site string, keepPasswords bool) error { + if output == "" { + output = filepath.Join(backups, backup.DefaultName(time.Now(), site)) + } + result, err := backup.Create(context.Background(), backup.CreateOptions{ + DSN: dsn, Files: files, Output: output, Site: site, + KeepPasswords: keepPasswords, Report: progress, + }) + if err != nil { + return err + } + m := result.Manifest + fmt.Printf("%s\n", result.Path) + fmt.Printf("%d tables, %d rows, %d files, %s\n", + len(m.Tables), m.TotalRows(), m.Files.Count, size(result.Bytes)) + return nil +} + +func verifyBackup(name string) error { + report, err := backup.Verify(name) + if err != nil { + return err + } + printReport(name, report) + if !report.OK() { + return fmt.Errorf("%s is not sound", filepath.Base(name)) + } + return nil +} + +func printReport(name string, report backup.Report) { + m := report.Manifest + fmt.Printf("%s\n", name) + fmt.Printf(" made %s by pwikit %s\n", m.CreatedAt.Format(time.RFC3339), m.Pwikit) + fmt.Printf(" postgres %s\n", backup.Describe(m.PGVersion)) + fmt.Printf(" holds %d tables, %d rows, %d files\n", len(m.Tables), m.TotalRows(), m.Files.Count) + for _, note := range report.Notes { + fmt.Printf(" note %s\n", note) + } + for _, problem := range report.Problems { + fmt.Printf(" PROBLEM %s\n", problem) + } + if report.OK() { + fmt.Println(" sound") + } +} + +func restoreBackup(name, dsn, files, backups string, force, noSafety bool) error { + holdsData, err := backup.Ready(context.Background(), dsn, force) + if err != nil { + return err + } + if !holdsData && !noSafety { + fmt.Println("the database holds no data yet, so there is nothing to back up first") + } + if holdsData && !noSafety { + safety := filepath.Join(backups, "before-restore-"+backup.DefaultName(time.Now(), "")) + fmt.Println("backing up the current state first") + result, err := backup.Create(context.Background(), backup.CreateOptions{ + DSN: dsn, Files: files, Output: safety, Report: progress, + }) + if err != nil { + return fmt.Errorf("could not back up the current state, so nothing was changed: %w", err) + } + fmt.Printf("the state before this restore is in %s\n", result.Path) + } + + result, err := backup.Restore(context.Background(), name, backup.RestoreOptions{ + DSN: dsn, Files: files, Force: force, Report: progress, + }) + if err != nil { + return err + } + fmt.Printf("restored %d rows into %d tables\n", result.Rows, len(result.Manifest.Tables)) + if result.FilesPut > 0 { + fmt.Printf("restored %d files\n", result.FilesPut) + } + if result.ReplacedDir != "" { + fmt.Printf("the files that were there are in %s\n", result.ReplacedDir) + } + if len(result.MigratedUp) > 0 { + fmt.Printf("brought the schema forward with %s\n", strings.Join(result.MigratedUp, ", ")) + } + return nil +} + +func listBackups(dir string) error { + found, err := backup.List(dir) + if err != nil { + if os.IsNotExist(err) { + fmt.Printf("no backups in %s\n", dir) + return nil + } + return err + } + if len(found) == 0 { + fmt.Printf("no backups in %s\n", dir) + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "FILE\tMADE\tPWIKIT\tPOSTGRES\tROWS\tFILES\tSIZE") + for _, one := range found { + if one.Problem != "" { + fmt.Fprintf(w, "%s\tUNREADABLE\t\t\t\t\t%s\n", filepath.Base(one.Path), size(one.Bytes)) + continue + } + m := one.Manifest + files := "no" + if m.Files.Included { + files = fmt.Sprint(m.Files.Count) + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", + filepath.Base(one.Path), m.CreatedAt.Format("2006-01-02 15:04"), + m.Pwikit, backup.Describe(m.PGVersion), m.TotalRows(), files, size(one.Bytes)) + } + if err := w.Flush(); err != nil { + return err + } + for _, one := range found { + if one.Problem != "" { + fmt.Fprintf(os.Stderr, "%s: %s\n", filepath.Base(one.Path), one.Problem) + } + } + return nil +} + +func progress(line string) { + fmt.Fprintf(os.Stderr, "\r\033[K%s", line) + if !strings.HasPrefix(line, "[") { + fmt.Fprintln(os.Stderr) + } +} + +func size(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for m := n / unit; m >= unit; m /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) +} diff --git a/cmd/pwikit/database.go b/cmd/pwikit/database.go new file mode 100644 index 00000000..2fe7335e --- /dev/null +++ b/cmd/pwikit/database.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/url" + "os" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/pgbundle" +) + +const stopBundledWithin = 2 * time.Minute + +func bundledConfig(p *paths.Paths, log *slog.Logger) pgbundle.Config { + return pgbundle.Config{ + Root: p.Root(), + Postgres: p.Postgres(), + Data: p.PGData(), + Secrets: p.Secrets(), + Logs: p.Logs(), + Log: log, + } +} + +func prepareDataDir(p *paths.Paths) error { + if err := p.EnsureBase(); err != nil { + return err + } + _, err := config.WriteTemplate(p.Config()) + return err +} + +const envDatabasePasswordFile = "PWIKIT_DATABASE_PASSWORD_FILE" + +func withPasswordFile(dsn string) (string, error) { + name := os.Getenv(envDatabasePasswordFile) + if name == "" || dsn == "" { + return dsn, nil + } + u, err := url.Parse(dsn) + if err != nil || u.User == nil { + return dsn, nil + } + if _, has := u.User.Password(); has { + return dsn, nil + } + secret, err := os.ReadFile(name) + if err != nil { + return "", fmt.Errorf("read the database password from %s: %w", name, err) + } + u.User = url.UserPassword(u.User.Username(), strings.TrimSpace(string(secret))) + return u.String(), nil +} + +func resolveDatabase(ctx context.Context, explicit, dataDir string) (string, func(), error) { + if explicit != "" { + dsn, err := withPasswordFile(explicit) + return dsn, func() {}, err + } + p, err := paths.New(dataDir) + if err != nil { + return "", nil, err + } + file, err := config.Load(p.Config()) + if err != nil { + return "", nil, err + } + if file.Database != "" { + dsn, err := withPasswordFile(file.Database) + return dsn, func() {}, err + } + cfg := bundledConfig(p, slog.Default()) + + server, err := pgbundle.Start(ctx, cfg, pgbundle.OwnerCommand) + var held *pgbundle.HeldError + if errors.As(err, &held) && held.Owner == pgbundle.OwnerServe { + dsn, err := pgbundle.Attach(ctx, cfg) + return dsn, func() {}, err + } + if err != nil { + return "", nil, noDatabase(err) + } + return server.DSN(), func() { stopBundled(server) }, nil +} + +func stopBundled(server *pgbundle.Server) { + ctx, cancel := context.WithTimeout(context.Background(), stopBundledWithin) + defer cancel() + if err := server.Stop(ctx); err != nil { + slog.Default().Warn("pwikit could not stop its PostgreSQL cleanly", "err", err) + } +} + +func noDatabase(err error) error { + return fmt.Errorf("%w\n To use a PostgreSQL of your own instead, pass -database or set %s", err, envDatabase) +} + +func startBundled(ctx context.Context, p *paths.Paths, log *slog.Logger) (*pgbundle.Server, error) { + if pgbundle.DefaultPortTaken(300 * time.Millisecond) { + fmt.Fprintln(os.Stderr, pgbundle.Hint()) + } + server, err := pgbundle.Start(ctx, bundledConfig(p, log), pgbundle.OwnerServe) + if err != nil { + return nil, noDatabase(err) + } + return server, nil +} diff --git a/cmd/pwikit/engine.go b/cmd/pwikit/engine.go new file mode 100644 index 00000000..188c37c0 --- /dev/null +++ b/cmd/pwikit/engine.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/renderer/sidecar" +) + +func newSidecarEngine(path string) (renderer.Renderer, func(), error) { + r, err := sidecar.New(path) + if err != nil { + return nil, nil, err + } + return r, func() { r.Close() }, nil +} diff --git a/cmd/pwikit/engine_linked.go b/cmd/pwikit/engine_linked.go new file mode 100644 index 00000000..9811efd5 --- /dev/null +++ b/cmd/pwikit/engine_linked.go @@ -0,0 +1,15 @@ +//go:build cgo && !nocgo + +package main + +import ( + "github.com/WikitTeam/ProjectWikit/internal/renderer" + ftml "github.com/WikitTeam/ProjectWikit/internal/renderer/cgo" +) + +func newRenderer(sidecarPath string) (renderer.Renderer, func(), error) { + if sidecarPath != "" { + return newSidecarEngine(sidecarPath) + } + return ftml.New(), func() {}, nil +} diff --git a/cmd/pwikit/engine_sidecar.go b/cmd/pwikit/engine_sidecar.go new file mode 100644 index 00000000..08678297 --- /dev/null +++ b/cmd/pwikit/engine_sidecar.go @@ -0,0 +1,16 @@ +//go:build !cgo || nocgo + +package main + +import ( + "errors" + + "github.com/WikitTeam/ProjectWikit/internal/renderer" +) + +func newRenderer(sidecarPath string) (renderer.Renderer, func(), error) { + if sidecarPath == "" { + return nil, nil, errors.New("this build has no ftml linked in; pass -sidecar or set " + envSidecar) + } + return newSidecarEngine(sidecarPath) +} diff --git a/cmd/pwikit/health.go b/cmd/pwikit/health.go new file mode 100644 index 00000000..e2b188ae --- /dev/null +++ b/cmd/pwikit/health.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/entry" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/update" + "github.com/WikitTeam/ProjectWikit/internal/version" +) + +// Set by the official image only. A marker file would also catch a whole +// machine run inside a container, which updates itself like any other. +const envContainer = "PWIKIT_CONTAINER" + +func inContainer() bool { + return os.Getenv(envContainer) != "" +} + +func serveHealth(p *paths.Paths, serving entry.Config, conn *db.DB, handler http.Handler, log *slog.Logger) (func(), error) { + listener, err := update.ListenHealth() + if err != nil { + return nil, err + } + check := func(ctx context.Context) update.Health { + h := update.Health{Version: version.String()} + hosts, err := conn.SiteDomains(ctx) + if err != nil { + h.Problem = err.Error() + return h + } + h.Database = true + if len(hosts) == 0 { + h.Page = http.StatusOK + return h + } + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + req.Host = hosts[0] + req.RemoteAddr = "127.0.0.1:1" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + h.Page = rec.Code + return h + } + server := &http.Server{Handler: update.HealthHandler(check), ReadHeaderTimeout: 10 * time.Second} + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Warn("the update health check stopped answering", "err", err) + } + }() + + hosts, _ := conn.SiteHosts(context.Background()) + record := update.Runtime{ + PID: os.Getpid(), + Version: version.String(), + StartedAt: time.Now().UTC(), + Health: listener.Addr().String(), + Mode: string(serving.Mode), + Plain: serving.Plain, + Secure: serving.Secure, + CertFile: serving.CertFile, + KeyFile: serving.KeyFile, + CacheDir: serving.CacheDir, + Email: serving.Email, + Directory: serving.Directory, + Hosts: hosts, + } + if err := update.WriteRuntime(p.Updates(), record); err != nil { + server.Close() + return nil, err + } + return func() { server.Close() }, nil +} diff --git a/cmd/pwikit/import.go b/cmd/pwikit/import.go new file mode 100644 index 00000000..50ee7a50 --- /dev/null +++ b/cmd/pwikit/import.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io/fs" + "os" + "slices" + + "github.com/WikitTeam/ProjectWikit/internal/archive" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/paths" +) + +func importCommand(args []string) error { + flags := flag.NewFlagSet("import", flag.ContinueOnError) + flags.Usage = func() { + fmt.Fprint(flags.Output(), `Usage: pwikit import [directory] [options] + +Imports an unpacked wikitCLI backup: a site directory, or the directory holding +several of them next to _users. Without a directory, archive/ in the state +directory is read. + +Options: +`) + flags.PrintDefaults() + } + database := flags.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + slug := flags.String("site", "", "slug of the site to write into; needed once a database holds more than one") + from := flags.String("from", "", "slug of the site inside the backup; needed when it holds more than one") + forceTags := flags.Bool("force-tags", false, "create tags this site would otherwise refuse") + noVotes := flags.Bool("no-votes", false, "leave the ratings behind") + noFiles := flags.Bool("no-files", false, "leave the attachments behind") + noAccounts := flags.Bool("no-accounts", false, "import even when the backup holds no accounts, leaving every author off") + dataDir := flags.String("data-dir", "", "state directory holding archive/ and receiving the attachments; defaults to the directory holding the executable") + loose, err := parseMixed(flags, args) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if len(loose) > 1 { + return fmt.Errorf("import takes one directory, got %d", len(loose)) + } + + p, err := paths.New(*dataDir) + if err != nil { + return err + } + dir := p.Archive() + if len(loose) == 1 { + dir = loose[0] + } else if _, err := os.Stat(dir); errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%s does not exist; put the unpacked backup there or name its directory", dir) + } + found, err := archive.Open(dir) + if err != nil { + return err + } + + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + current, err := resolveSite(ctx, conn, *slug) + if err != nil { + return err + } + + files := "" + if !*noFiles { + if err := p.EnsureBase(); err != nil { + return err + } + files = p.Files() + } + return importArchive(ctx, conn, current, found, *from, archive.Options{ + ForceTags: *forceTags, + Votes: !*noVotes, + Files: files, + WithoutAccounts: *noAccounts, + }) +} + +func importArchive(ctx context.Context, conn *db.DB, current *db.Site, found *archive.Archive, from string, opts archive.Options) error { + slugs := found.Sites() + switch { + case from == "" && len(slugs) > 1: + return fmt.Errorf("the backup holds %d sites, name one with -from", len(slugs)) + case from == "": + from = slugs[0] + case !slices.Contains(slugs, from): + return fmt.Errorf("the backup has no site %q", from) + } + + fmt.Printf("importing %s into %s\n", from, current.Slug) + opts.Report = func(line string) { fmt.Println(line) } + result, err := archive.ImportPages(ctx, conn, current.ID, found, from, opts) + if errors.Is(err, archive.ErrNoAccounts) { + return fmt.Errorf("%w, so nothing was imported. The accounts are in a _users directory, "+ + "usually beside the site directory; import the directory holding both, "+ + "or pass -no-accounts to import without authors", err) + } + fmt.Printf("%d pages, %d already there, %d revisions, %d parents, %d files, %d accounts\n", + result.Pages, result.Skipped, result.Revisions, result.Parents, result.Files, result.Users) + fmt.Printf("%d forum categories, %d threads, %d posts\n", + result.Categories, result.Threads, result.Posts) + return err +} diff --git a/cmd/pwikit/logfile.go b/cmd/pwikit/logfile.go new file mode 100644 index 00000000..f56b5644 --- /dev/null +++ b/cmd/pwikit/logfile.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/WikitTeam/ProjectWikit/internal/logfile" +) + +func openLog(name string) (*slog.Logger, func(), error) { + if name == "" { + return slog.Default(), func() {}, nil + } + if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil { + return nil, nil, fmt.Errorf("create %s: %w", filepath.Dir(name), err) + } + w, err := logfile.Open(name, logfile.DefaultLimit, logfile.DefaultKeep) + if err != nil { + return nil, nil, err + } + log := slog.New(slog.NewTextHandler(w, nil)) + slog.SetDefault(log) + return log, func() { w.Close() }, nil +} diff --git a/cmd/pwikit/main.go b/cmd/pwikit/main.go new file mode 100644 index 00000000..01214307 --- /dev/null +++ b/cmd/pwikit/main.go @@ -0,0 +1,844 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + iofs "io/fs" + "net" + "net/http" + "os" + "os/signal" + "regexp" + "slices" + "strconv" + "strings" + "syscall" + "text/tabwriter" + + "github.com/WikitTeam/ProjectWikit/internal/account" + "github.com/WikitTeam/ProjectWikit/internal/admin" + "github.com/WikitTeam/ProjectWikit/internal/backup" + "github.com/WikitTeam/ProjectWikit/internal/compress" + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/entry" + "github.com/WikitTeam/ProjectWikit/internal/localitem" + "github.com/WikitTeam/ProjectWikit/internal/media" + "github.com/WikitTeam/ProjectWikit/internal/migrate" + "github.com/WikitTeam/ProjectWikit/internal/module" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/pgbundle" + "github.com/WikitTeam/ProjectWikit/internal/proxyheader" + "github.com/WikitTeam/ProjectWikit/internal/respheader" + "github.com/WikitTeam/ProjectWikit/internal/routing" + "github.com/WikitTeam/ProjectWikit/internal/secretfile" + "github.com/WikitTeam/ProjectWikit/internal/seed" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/static" + "github.com/WikitTeam/ProjectWikit/internal/update" + "github.com/WikitTeam/ProjectWikit/internal/userpage" + "github.com/WikitTeam/ProjectWikit/internal/version" + "github.com/WikitTeam/ProjectWikit/internal/webapi" + staticfiles "github.com/WikitTeam/ProjectWikit/static" +) + +const ( + envDatabase = "DATABASE_URL" + envSecretKey = "SECRET_KEY" + envGoogleTag = "GOOGLE_TAG_ID" + envUploadLimit = "MEDIA_UPLOAD_LIMIT" + envMailHost = "EMAIL_HOST" + envMailPort = "EMAIL_PORT" + envMailUser = "EMAIL_USERNAME" + envMailPassword = "EMAIL_PASSWORD" + envMailTLS = "EMAIL_USE_TLS" + envMailImplicit = "EMAIL_IMPLICIT_TLS" + envMailEngine = "EMAIL_ENGINE" + envMailFrom = "EMAIL_DEFAULT_FROM" + envStorageLimit = "ABSOLUTE_MEDIA_UPLOAD_LIMIT" + envTLS = "PWIKIT_TLS" + envTLSCert = "PWIKIT_TLS_CERT" + envTLSKey = "PWIKIT_TLS_KEY" + envTLSListen = "PWIKIT_TLS_LISTEN" + envACMEEmail = "PWIKIT_ACME_EMAIL" + envACMEDir = "PWIKIT_ACME_DIRECTORY" + envUpdateAuto = "PWIKIT_UPDATE_AUTO" + envUpdateBanner = "PWIKIT_UPDATE_PUBLIC_BANNER" + envUpdateCheck = "PWIKIT_UPDATE_CHECK" + envUpdateWindow = "PWIKIT_UPDATE_WINDOW" + envUpdateMinAge = "PWIKIT_UPDATE_MIN_AGE" + envUpdateMirror = "PWIKIT_UPDATE_MIRROR" + defaultListen = "127.0.0.1:8080" + defaultTLSPlain = ":80" + defaultTLSAddr = ":443" + sessionKeyFile = "session-key" +) + +func main() { + if handled, err := runAsService(os.Args[1:]); handled || err != nil { + if err != nil { + fmt.Fprintln(os.Stderr, "pwikit: "+err.Error()) + os.Exit(1) + } + return + } + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "pwikit: "+err.Error()) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + usage() + return errors.New("missing subcommand") + } + switch args[0] { + case "serve": + return serve(context.Background(), args[1:]) + case "modules": + return printModules() + case "render": + return render(args[1:]) + case "migrate": + return migrateCommand(args[1:]) + case "createsite": + return createSite(args[1:]) + case "site": + return siteCommand(args[1:]) + case "admin": + return adminCommand(args[1:]) + case "backup": + return backupCommand(args[1:]) + case "seed": + return seedPages(args[1:]) + case "import": + return importCommand(args[1:]) + case "reindex": + return reindex(args[1:]) + case "service": + return serviceCommand(args[1:]) + case "path": + return pathCommand(args[1:]) + case "update": + return updateCommand(args[1:]) + case "version", "-version", "--version": + return printVersion() + case "help", "-h", "--help": + usage() + return nil + default: + usage() + return fmt.Errorf("unknown subcommand %q", args[0]) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `Usage: pwikit [options] + +Commands: + serve start the HTTP server + createsite create the site this database serves + site list the sites in this database or point one at another domain + admin create an administrator or give an account every right + backup write, check, list or put back a backup + seed write the pages a new site starts with + import import an unpacked wikidot backup + reindex put every page of a site back into the search index + service start pwikit whenever the machine boots + path make pwikit runnable by name from any directory + update install a newer release, or put back the one before it + render render wikitext read from stdin or a file + migrate apply or inspect the schema migrations + modules print the wikidot module list + version show which release this is + help show this help +`) +} + +func serve(ctx context.Context, args []string) (err error) { + o := newServeOptions() + if err := o.fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + p, err := paths.New(*o.dataDir) + if err != nil { + return err + } + if err := prepareDataDir(p); err != nil { + return err + } + cfg, err := config.Load(p.Config()) + if err != nil { + return err + } + mode, err := o.resolve(cfg) + if err != nil { + return err + } + updates, err := o.updateSettings(cfg) + if err != nil { + return err + } + if *o.secret == "" { + if *o.secret, err = secretfile.Ensure(p.Secrets(), sessionKeyFile); err != nil { + return err + } + } + + log, closeLog, err := openLog(*o.logFile) + if err != nil { + return err + } + defer closeLog() + if cfg.Mail.Password != "" && config.ReadableByOthers(p.Config()) { + log.Warn("pwikit.toml holds the mail password and other accounts on this machine can read it", "path", p.Config()) + } + + // Caught this early so a stop during a slow PostgreSQL start still stops PostgreSQL. + ctx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stopSignals() + + dsn, err := withPasswordFile(*o.database) + if err != nil { + return err + } + bundledPostgres := "" + if dsn == "" { + bundledPostgres = pgbundle.Version + bundled, err := startBundled(ctx, p, log) + if err != nil { + return err + } + defer stopBundled(bundled) + dsn = bundled.DSN() + + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + defer cancel() + go func() { + select { + case <-bundled.Exited(): + log.Error("PostgreSQL stopped while pwikit was serving") + cancel() + case <-ctx.Done(): + } + }() + defer func() { + select { + case <-bundled.Exited(): + if err == nil { + err = bundled.ExitError() + } + default: + } + }() + } + + trust, err := proxyheader.NewTrust(strings.Split(*o.trusted, ",")) + if err != nil { + return err + } + + assets, err := assetFS(*o.staticDir) + if err != nil { + return err + } + if assets == nil { + log.Warn("this build carries no page assets and -static-dir is not set, so pages are served without styles and scripts") + } + + // Asked before anything writes, so a server too old to hold the schema says + // so instead of failing somewhere in the middle of a migration. + found, err := backup.CheckServer(ctx, dsn) + if err != nil { + return err + } + log.Info("pwikit reached postgres", "version", backup.Describe(found)) + + if !*o.noMigrate { + result, err := migrate.Run(ctx, dsn) + if err != nil { + return err + } + if result.Adopted { + log.Info("pwikit adopted the schema", "baseline", migrate.BaselineName) + } + for _, name := range result.Applied { + log.Info("pwikit applied a migration", "name", name) + } + if len(result.Newer) > 0 { + log.Warn("the database holds migrations from a newer pwikit that this one runs alongside", + "migrations", strings.Join(result.Newer, ", ")) + } + } + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + hostsBound, err := conn.SiteHosts(ctx) + if err != nil { + return err + } + if domain, ok := o.promote(hostsBound); ok { + mode = entry.Auto + log.Info("pwikit serves HTTPS because a site is bound to a public domain", "domain", domain) + } + if *o.dev { + log.Info("pwikit is in development mode, and only this machine can reach it", "url", "http://"+*o.listen) + } + + notFound := http.NotFoundHandler() + mediaHandler := site.NewHostRules(conn, listenPort(*o.listen), media.New(p.Files(), conn), notFound) + resizedHandler := site.NewHostRules(conn, listenPort(*o.listen), media.NewResized(p.Files(), conn), notFound) + + soft, err := parseSize(*o.uploadLimit) + if err != nil { + return fmt.Errorf("parse -upload-limit: %w", err) + } + hard, err := parseSize(*o.storageLimit) + if err != nil { + return fmt.Errorf("parse -storage-limit: %w", err) + } + + board := &update.Board{ + DB: conn, Settings: updates, Current: version.String(), + BundledPostgres: bundledPostgres, Container: inContainer(), + } + stack, err := newPageStack(conn, p, assets, notFound, trust, limits{soft: soft, hard: hard}, + *o.sidecar, *o.secret, cfg, board, log) + if err != nil { + return err + } + defer stack.close() + // Only the page handler answers a name a person typed, so only it explains + // an unresolved one. The rest are reached from inside a page. + served := func(h http.Handler) http.Handler { + return compress.New(board.Wrap(respheader.VaryCookie(site.NewHostRules(conn, listenPort(*o.listen), h, stack.unresolved)))) + } + articles := served(stack.articles) + codeHandler := served(stack.code) + htmlHandler := served(stack.html) + themeHandler := served(stack.theme) + moduleAPI := served(stack.moduleAPI) + preview := served(stack.preview) + profile := served(stack.profile) + profileForm := served(stack.profileForm) + reactivePages := served(stack.reactivePages) + notifyAPI := served(stack.notifyAPI) + subscribeAPI := served(stack.subscribeAPI) + messageAPI := served(stack.messageAPI) + userAPI := served(stack.userAPI) + adminAPI := served(stack.adminAPI) + login := served(stack.login) + logout := served(stack.logout) + signup := served(stack.signup) + accept := served(stack.accept) + reset := served(stack.reset) + tickets := served(stack.tickets) + emailLinks := served(stack.emailLinks) + settings := served(stack.settings) + adminPages := served(stack.adminPages) + favesAPI := served(stack.favesAPI) + ownRowsAPI := served(stack.ownRowsAPI) + articleAPI := served(stack.articleAPI) + allArticles := served(stack.allArticles) + fileAPI := served(stack.fileAPI) + + // A system path nobody claims is a mistyped URL, not a page name, so these + // two answer before the article handler sees them. + goHandlers := map[string]http.Handler{ + "/-/": notFound, + "/pw-api/": notFound, + static.Prefix: static.New(assets, notFound), + site.ThemePrefix: respheader.VaryCookie(site.NewHostRules(conn, listenPort(*o.listen), site.NewThemeFiles(p.Files()), notFound)), + media.Prefix: respheader.VaryCookie(mediaHandler), + media.ResizedPrefix: respheader.VaryCookie(resizedHandler), + localitem.CodePrefix: codeHandler, + localitem.HTMLPrefix: htmlHandler, + localitem.ThemePrefix: themeHandler, + webapi.ModulesPath: moduleAPI, + webapi.PreviewPath: preview, + userpage.Prefix: profile, + userpage.EditPrefix: profileForm, + account.LoginPath: login, + account.LogoutPath: logout, + account.SignupPath: signup, + account.SignupPrefix: signup, + account.AcceptPrefix: accept, + account.ResetPath: reset, + account.ResetPrefix: reset, + account.ResetConfirmPath: reset, + account.TicketPath: tickets, + account.MembershipPath: tickets, + account.EmailPrefix: emailLinks, + account.SettingsPrefix: settings, + admin.Bare: adminPages, + admin.Prefix: adminPages, + userpage.FavouritesPrefix: reactivePages, + webapi.NotificationsPath: notifyAPI, + webapi.SubscribePath: subscribeAPI, + webapi.MessagesPrefix: messageAPI, + webapi.UsersPath: userAPI, + webapi.UsersPrefix: userAPI, + webapi.AdminPrefix: adminAPI, + webapi.FavouritesPath: favesAPI, + webapi.RatingsPath: ownRowsAPI, + webapi.LikedPostsPath: ownRowsAPI, + userpage.RatingsPrefix: reactivePages, + userpage.NotificationsPrefix: reactivePages, + userpage.NotificationsSubPrefix: reactivePages, + userpage.MessagesPrefix: reactivePages, + userpage.MessagesSubPrefix: reactivePages, + userpage.LikedPostsPrefix: reactivePages, + webapi.AllArticlesPath: allArticles, + webapi.ArticlesPrefix: articleAPI, + webapi.FilesPrefix: fileAPI, + "/": articles, + } + + mux, err := routing.New(goHandlers) + if err != nil { + return err + } + + log.Info("pwikit serve", "listen", *o.listen, "root", p.Root(), + "root_source", string(p.Source()), "static_dir", *o.staticDir, "assets_embedded", staticfiles.Embedded) + + var hosts entry.Hosts + if conn != nil { + hosts = func(ctx context.Context, host string) error { + known, err := conn.SiteHostExists(ctx, host) + if err != nil { + return err + } + if !known { + return fmt.Errorf("host %q is not a site on this server", host) + } + return nil + } + } else if mode == entry.Auto { + return errors.New("-tls=auto needs -database to know which hosts to obtain certificates for") + } + + handler := respheader.OriginPolicy(mux) + serving := entry.Config{ + Mode: mode, + Plain: *o.listen, + Secure: *o.tlsListen, + CertFile: *o.tlsCert, + KeyFile: *o.tlsKey, + CacheDir: p.Certs(), + Email: *o.acmeEmail, + Directory: *o.acmeDirectory, + Hosts: hosts, + Handler: handler, + Logger: log, + } + stopHealth, err := serveHealth(p, serving, conn, handler, log) + if err != nil { + return err + } + defer stopHealth() + return entry.Serve(ctx, serving) +} + +func given(fs *flag.FlagSet, name string) bool { + found := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + +func listenPort(addr string) string { + _, port, err := net.SplitHostPort(addr) + if err != nil { + return "" + } + return port +} + +func assetFS(dir string) (iofs.FS, error) { + if dir == "" { + if staticfiles.Embedded { + return staticfiles.Files, nil + } + return nil, nil + } + info, err := os.Stat(dir) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("static-dir %q is not a directory", dir) + } + return os.DirFS(dir), nil +} + +var sizeUnits = map[string]int64{"B": 1, "KB": 1 << 10, "MB": 1 << 20, "GB": 1 << 30, "TB": 1 << 40} + +func parseSize(spec string) (int64, error) { + digits := strings.TrimLeft(spec, "0123456789") + number, err := strconv.ParseInt(spec[:len(spec)-len(digits)], 10, 64) + if err != nil { + return 0, fmt.Errorf("no leading number in %q", spec) + } + unit := strings.ToUpper(strings.TrimSpace(digits)) + if unit == "" { + return number, nil + } + scale, ok := sizeUnits[unit] + if !ok { + return 0, fmt.Errorf("unknown size unit %q", unit) + } + return number * scale, nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func seedPages(args []string) error { + fs := flag.NewFlagSet("seed", flag.ContinueOnError) + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + slug := fs.String("site", "", "slug of the site to write into; needed once a database holds more than one") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + current, err := resolveSite(ctx, conn, *slug) + if err != nil { + return err + } + + written, err := seed.Run(ctx, conn, current.ID) + for _, name := range written { + fmt.Println("wrote " + name) + } + if err != nil { + return err + } + fmt.Printf("%d of %d pages written, the rest were already there\n", len(written), len(seed.Names())) + return nil +} + +var slugPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func createSite(args []string) error { + fs := flag.NewFlagSet("createsite", flag.ContinueOnError) + slug := fs.String("slug", "", "short name for the site, letters, digits, - and _") + domain := fs.String("domain", "", "domain the pages are served on") + mediaDomain := fs.String("media-domain", "", "domain the uploaded files are served on; defaults to -domain") + title := fs.String("title", "", "site title") + headline := fs.String("headline", "", "site subtitle") + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + for name, value := range map[string]string{"slug": *slug, "domain": *domain, "title": *title, "headline": *headline} { + if value == "" { + return fmt.Errorf("no -%s", name) + } + } + if !slugPattern.MatchString(*slug) { + return fmt.Errorf("slug %q may only hold letters, digits, - and _", *slug) + } + if *mediaDomain == "" { + mediaDomain = domain + } + for name, value := range map[string]string{"domain": *domain, "media-domain": *mediaDomain} { + if !site.ValidHost(value) { + return fmt.Errorf("-%s %q is not a host name; give the name a request arrives on, without a scheme or a path", name, value) + } + } + // createsite is the first command a new instance runs, so the layout and the + // settings file should be there to look at before serve ever starts. + p, err := paths.New(*dataDir) + if err != nil { + return err + } + if err := prepareDataDir(p); err != nil { + return err + } + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + if _, err := backup.CheckServer(ctx, dsn); err != nil { + return err + } + // A database nothing has started on yet has no tables to put the site in. + result, err := migrate.Run(ctx, dsn) + if err != nil { + return err + } + if result.Adopted { + fmt.Printf("adopted %s\n", migrate.BaselineName) + } + for _, name := range result.Applied { + fmt.Printf("applied %s\n", name) + } + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + id, err := conn.CreateSite(ctx, db.NewSite{ + Slug: *slug, Title: *title, Headline: *headline, + Domain: *domain, MediaDomain: *mediaDomain, + }) + if err != nil { + return err + } + fmt.Printf("created site %s (%d) on %s\n", *slug, id, *domain) + announceHTTPS(*domain) + return nil +} + +func migrateCommand(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + if sub != "status" && sub != "up" { + fmt.Fprint(os.Stderr, `Usage: pwikit migrate [-database ] [-data-dir ] + + status print which schema migrations the database carries + up apply the migrations the database is missing +`) + return errors.New("unknown migrate subcommand") + } + fs := flag.NewFlagSet("migrate "+sub, flag.ContinueOnError) + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + if sub == "up" { + return migrateUp(dsn) + } + + state, err := migrate.Status(ctx, dsn) + if err != nil { + return err + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "STATUS\tMIGRATION") + for _, name := range state.Applied { + fmt.Fprintf(w, "applied\t%s\n", name) + } + for _, name := range state.Unknown { + status := "newer" + if slices.Contains(state.UnknownBreaking, name) { + status = "newer-breaking" + } + fmt.Fprintf(w, "%s\t%s\n", status, name) + } + for _, name := range state.Pending { + status := "pending" + if state.Adoptable && name == migrate.BaselineName { + status = "existing" + } + fmt.Fprintf(w, "%s\t%s\n", status, name) + } + return w.Flush() +} + +func migrateUp(dsn string) error { + result, err := migrate.Run(context.Background(), dsn) + if err != nil { + return err + } + if result.Adopted { + fmt.Printf("adopted %s\n", migrate.BaselineName) + } + for _, name := range result.Applied { + fmt.Printf("applied %s\n", name) + } + if !result.Adopted && len(result.Applied) == 0 { + fmt.Println("already up to date") + } + return nil +} + +func printModules() error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "MODULE\tBODY\tSTATUS") + for _, info := range module.All() { + status := "pending" + switch { + case info.Removed: + status = "removed" + case module.Ported(info.Name): + status = "ported" + } + fmt.Fprintf(w, "%s\t%t\t%s\n", info.Name, info.HasContent, status) + } + return w.Flush() +} + +func resolveSite(ctx context.Context, conn *db.DB, slug string) (*db.Site, error) { + if slug != "" { + found, err := conn.SiteBySlug(ctx, slug) + if errors.Is(err, db.ErrNotFound) { + return nil, fmt.Errorf("no site with slug %q", slug) + } + return found, err + } + slugs, err := conn.SiteSlugs(ctx) + if err != nil { + return nil, err + } + switch len(slugs) { + case 0: + return nil, errors.New("this database holds no site, make one with createsite") + case 1: + return conn.SiteBySlug(ctx, slugs[0]) + } + return nil, fmt.Errorf("this database holds %d sites, name one with -site", len(slugs)) +} + +func siteCommand(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + if sub != "list" && sub != "rebind" { + fmt.Fprint(os.Stderr, `Usage: pwikit site [options] + + list print the slug and the two domains of every site + rebind point a site at another domain, for when the stored one cannot be reached + +Options for rebind: + -slug site to rebind + -domain domain the pages are served on + -media-domain domain the uploaded files are served on; defaults to -domain +`) + return errors.New("unknown site subcommand") + } + fs := flag.NewFlagSet("site "+sub, flag.ContinueOnError) + slug := fs.String("slug", "", "site to rebind") + domain := fs.String("domain", "", "domain the pages are served on") + mediaDomain := fs.String("media-domain", "", "domain the uploaded files are served on; defaults to -domain") + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + if sub == "list" { + return listSites(ctx, conn) + } + return rebindSite(ctx, conn, *slug, *domain, *mediaDomain) +} + +func listSites(ctx context.Context, conn *db.DB) error { + slugs, err := conn.SiteSlugs(ctx) + if err != nil { + return err + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "SLUG\tDOMAIN\tMEDIA DOMAIN") + for _, slug := range slugs { + s, err := conn.SiteBySlug(ctx, slug) + if err != nil { + return err + } + fmt.Fprintf(w, "%s\t%s\t%s\n", s.Slug, s.Domain, s.MediaDomain) + } + return w.Flush() +} + +func rebindSite(ctx context.Context, conn *db.DB, slug, domain, mediaDomain string) error { + if slug == "" || domain == "" { + return errors.New("rebind needs -slug and -domain") + } + if mediaDomain == "" { + mediaDomain = domain + } + for name, value := range map[string]string{"domain": domain, "media-domain": mediaDomain} { + if !site.ValidHost(value) { + return fmt.Errorf("-%s %q is not a host name; give the name a request arrives on, without a scheme or a path", name, value) + } + } + before, err := conn.SiteBySlug(ctx, slug) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return fmt.Errorf("no site with slug %q", slug) + } + return err + } + if err := conn.SetSiteHosts(ctx, slug, domain, mediaDomain); err != nil { + return err + } + fmt.Printf("%s: %s -> %s\n", slug, before.Domain, domain) + fmt.Printf("%s: %s -> %s (media)\n", slug, before.MediaDomain, mediaDomain) + announceHTTPS(domain) + return nil +} + +func announceHTTPS(domain string) { + if site.PublicHost(domain) { + fmt.Printf("https://%s answers once pwikit serve starts, or restarts if it is already running\n", domain) + } +} diff --git a/cmd/pwikit/path.go b/cmd/pwikit/path.go new file mode 100644 index 00000000..0ed5ab39 --- /dev/null +++ b/cmd/pwikit/path.go @@ -0,0 +1,179 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/shellpath" +) + +func pathUsage() { + fmt.Fprint(os.Stderr, `Usage: pwikit path [options] + + install make pwikit runnable by name from any directory + uninstall undo install + status show where the pwikit command is reachable from + +Options: + -dir directory to put the command in on Linux and macOS; defaults to + /usr/local/bin, or ~/.local/bin when that cannot be written + -force replace a command of the same name that leads somewhere else + +On Windows, install adds the directory holding pwikit to your own Path. +`) +} + +func pathCommand(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "install", "uninstall", "status": + default: + pathUsage() + return errors.New("unknown path subcommand") + } + + fs := flag.NewFlagSet("path "+sub, flag.ContinueOnError) + dir := fs.String("dir", "", "directory to put the command in on Linux and macOS") + force := fs.Bool("force", false, "replace a command of the same name that leads somewhere else") + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + exe, err := runnableExecutable() + if err != nil { + return err + } + switch sub { + case "install": + return installPath(exe, *dir, *force) + case "uninstall": + return uninstallPath(exe, *dir) + } + return pathStatus(exe, *dir) +} + +// A program run from go run sits in a temporary directory that is gone when it +// exits, so nothing may be pointed at it. +func runnableExecutable() (string, error) { + p, err := paths.New("") + if err != nil { + return "", err + } + if p.Source() == paths.SourceGoRun { + return "", errors.New("go run leaves the program in a temporary directory; build pwikit and run that instead") + } + exe, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return exe, nil +} + +func installPath(exe, dir string, force bool) error { + place, outcome, err := shellpath.Install(exe, dir, force) + if err != nil { + return err + } + switch { + case outcome == shellpath.Unchanged: + fmt.Printf("pwikit is already reachable through %s\n", place.Path) + case runtime.GOOS == "windows": + fmt.Printf("added %s to your Path\n", place.Path) + case outcome == shellpath.Replaced: + fmt.Printf("pointed %s at %s, replacing an older one\n", place.Path, exe) + default: + fmt.Printf("pointed %s at %s\n", place.Path, exe) + } + printReach(place) + return nil +} + +func uninstallPath(exe, dir string) error { + place, outcome, err := shellpath.Uninstall(exe, dir) + if err != nil { + return err + } + if outcome == shellpath.Absent { + fmt.Println("pwikit was not on the PATH through this command") + return nil + } + fmt.Println(removedMessage(place)) + return nil +} + +func removedMessage(place shellpath.Place) string { + if runtime.GOOS == "windows" { + return "removed " + place.Path + " from your Path" + } + return "removed " + place.Path +} + +func pathStatus(exe, dir string) error { + places, err := shellpath.Status(exe, dir) + if err != nil { + return err + } + if len(places) == 0 { + fmt.Println("pwikit is not on the PATH; run ./pwikit path install from its directory") + return nil + } + for _, place := range places { + state := "leads to this pwikit" + switch { + case !place.Working: + state = "leads nowhere; run ./pwikit path install from the new directory" + case !place.Ours: + state = "leads to another pwikit at " + place.Target + } + fmt.Printf("%s: %s\n", place.Path, state) + printReach(place) + } + return nil +} + +func printReach(place shellpath.Place) { + if runtime.GOOS == "windows" { + fmt.Println(" open a new terminal for the change to take effect") + return + } + if !place.OnPath { + fmt.Printf(" %s is not on your PATH; add it in your shell profile\n", filepath.Dir(place.Path)) + } +} + +// Setting up the service is when pwikit is being put in place for good, so the +// command goes on the PATH then too. A failure here leaves the service working. +func servicePath(exe string, install bool) { + if install { + place, _, err := shellpath.Install(exe, "", false) + if err != nil { + fmt.Printf(" could not put pwikit on the PATH: %v\n run pwikit path install to try again\n", err) + return + } + fmt.Printf(" pwikit can now be run by name through %s\n", place.Path) + printReach(place) + return + } + place, outcome, err := shellpath.Uninstall(exe, "") + if err != nil { + fmt.Printf(" could not take pwikit off the PATH: %v\n", err) + return + } + if outcome == shellpath.Removed { + fmt.Println(" " + removedMessage(place)) + } +} diff --git a/cmd/pwikit/reindex.go b/cmd/pwikit/reindex.go new file mode 100644 index 00000000..68bd1200 --- /dev/null +++ b/cmd/pwikit/reindex.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "os" + + "github.com/WikitTeam/ProjectWikit/internal/callbacks" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/printuser" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/repo" + "github.com/WikitTeam/ProjectWikit/internal/roles" +) + +func reindex(args []string) error { + fs := flag.NewFlagSet("reindex", flag.ContinueOnError) + database := fs.String("database", os.Getenv(envDatabase), "PostgreSQL connection string") + slug := fs.String("site", "", "slug of the site to go through; needed once a database holds more than one") + all := fs.Bool("all", false, "go through every site in this database") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + sidecar := fs.String("sidecar", os.Getenv(envSidecar), "path to the ftml sidecar binary; without it the linked-in ftml is used") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *database, *dataDir) + if err != nil { + return err + } + defer release() + conn, err := db.Open(ctx, dsn) + if err != nil { + return err + } + defer conn.Close() + + sites, err := reindexSites(ctx, conn, *slug, *all) + if err != nil { + return err + } + p, err := paths.New(*dataDir) + if err != nil { + return err + } + bundle, err := i18n.Load(p.Locales()) + if err != nil { + return err + } + engine, closeEngine, err := newRenderer(*sidecar) + if err != nil { + return err + } + defer closeEngine() + + loc := bundle.Localizer(i18n.DefaultLanguage) + store := cliRepository{data: repo.New(ctx, conn, printuser.New(loc, roles.FileIcons(p.Files())), repo.Options{Loc: loc})} + for _, current := range sites { + written, err := reindexSite(ctx, conn, engine, store, loc, current) + if err != nil { + return err + } + fmt.Printf("%s: %d pages indexed\n", current.Slug, written) + } + return nil +} + +func reindexSites(ctx context.Context, conn *db.DB, slug string, all bool) ([]*db.Site, error) { + if !all { + current, err := resolveSite(ctx, conn, slug) + if err != nil { + return nil, err + } + return []*db.Site{current}, nil + } + if slug != "" { + return nil, errors.New("give either -site or -all, not both") + } + slugs, err := conn.SiteSlugs(ctx) + if err != nil { + return nil, err + } + out := make([]*db.Site, 0, len(slugs)) + for _, one := range slugs { + current, err := conn.SiteBySlug(ctx, one) + if err != nil { + return nil, err + } + out = append(out, current) + } + return out, nil +} + +func reindexSite(ctx context.Context, conn *db.DB, engine renderer.Renderer, store cliRepository, + loc *i18n.Localizer, current *db.Site) (int, error) { + + listed, err := conn.ListArticles(ctx, db.ListFilter{SiteID: current.ID}, 0, nil) + if err != nil { + return 0, err + } + written := 0 + for i := range listed { + article := &listed[i] + source, err := conn.LatestSource(ctx, article.ID) + if errors.Is(err, db.ErrNotFound) { + continue + } + if err != nil { + return written, err + } + if source == "" { + continue + } + indexed := article.Title + "\n\n" + source + plaintext := indexed + // The same fallback the editor uses, so a page the engine cannot read is + // still searchable by its own words. + if text, err := reindexText(ctx, engine, store, loc, conn, current, article, source); err == nil { + plaintext = article.Title + "\n\n" + text + } + if err := conn.UpdateSearchIndex(ctx, article.ID, indexed, plaintext); err != nil { + return written, err + } + written++ + } + return written, nil +} + +func reindexText(ctx context.Context, engine renderer.Renderer, store cliRepository, loc *i18n.Localizer, + conn *db.DB, current *db.Site, article *db.Article, source string) (string, error) { + + vars := page.NewVars(article, nil, repo.NewVarSource(ctx, conn, current), loc) + cb := callbacks.New(loc, store) + cb.SetSite(current.Slug) + cb.SetPageVars(vars) + cb.SetContext(page.NewContext(article, article, nil, nil)) + + info := renderer.PageInfo{ + Page: article.Name, Category: article.Category, + Site: current.Slug, Domain: current.Domain, Title: article.Title, + } + result, err := engine.RenderText(ctx, page.PreRender(source, vars), info, cb, renderer.ModeSystem) + if err != nil { + return "", err + } + return result.Body, nil +} diff --git a/cmd/pwikit/render.go b/cmd/pwikit/render.go new file mode 100644 index 00000000..9ae19f58 --- /dev/null +++ b/cmd/pwikit/render.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/callbacks" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/printuser" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/repo" + "github.com/WikitTeam/ProjectWikit/internal/roles" +) + +const envSidecar = "PWIKIT_FTML_SIDECAR" + +func render(args []string) error { + fs := flag.NewFlagSet("render", flag.ContinueOnError) + file := fs.String("file", "", "read wikitext from this file instead of stdin") + mode := fs.String("mode", string(renderer.ModeArticle), "wikitext mode: article, message, inline, system, system-with-modules") + output := fs.String("output", "html", "what to produce: html, text, backlinks, code") + dsn := fs.String("dsn", "", "PostgreSQL connection string; without it links are never resolved and includes always miss") + dataDir := fs.String("data-dir", "", "state directory holding role icons; defaults to the directory holding the executable") + sidecar := fs.String("sidecar", os.Getenv(envSidecar), "path to the ftml sidecar binary; without it the linked-in ftml is used") + trace := fs.String("trace", "", "write the callback sequence to this file, or - for stderr") + pageName := fs.String("page", "page", "page name reported to ftml") + category := fs.String("category", "_default", "page category reported to ftml") + domain := fs.String("domain", "example.org", "site domain reported to ftml") + siteSlug := fs.String("site", "", "site slug an include may name to mean this wiki") + modules := fs.String("modules", "real", "how to answer modules: real, stub") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + + if !renderer.Mode(*mode).Valid() { + return fmt.Errorf("unknown mode %q", *mode) + } + if *modules != "real" && *modules != "stub" { + return fmt.Errorf("unknown modules %q", *modules) + } + + source, err := readSource(*file) + if err != nil { + return err + } + + ctx := context.Background() + engine, closeEngine, err := newRenderer(*sidecar) + if err != nil { + return err + } + defer closeEngine() + + bundle, err := i18n.Load("") + if err != nil { + return err + } + + store := cliRepository{stub: *modules == "stub"} + var ( + vars *page.Vars + article *db.Article + ) + if *dsn != "" { + conn, err := db.Open(ctx, *dsn) + if err != nil { + return err + } + defer conn.Close() + p, err := paths.New(*dataDir) + if err != nil { + return err + } + users := printuser.New(bundle.Localizer(i18n.DefaultLanguage), roles.FileIcons(p.Files())) + store.data = repo.New(ctx, conn, users, repo.Options{Loc: bundle.Localizer(i18n.DefaultLanguage)}) + current, err := resolveSite(ctx, conn, *siteSlug) + if err != nil { + return err + } + vars, article, err = cliPageVars(ctx, conn, bundle.Localizer(i18n.DefaultLanguage), current, *category, *pageName) + if err != nil { + return err + } + } + + cb := callbacks.New(bundle.Localizer(i18n.DefaultLanguage), store) + cb.SetSite(*siteSlug) + cb.SetPageVars(vars) + if article != nil { + cb.SetContext(page.NewContext(article, article, nil, nil)) + } + source = page.PreRender(source, vars) + var handler renderer.Callbacks = cb + var recorder *tracer + if *trace != "" { + recorder = &tracer{inner: cb} + handler = recorder + } + + info := renderer.PageInfo{Page: *pageName, Category: *category, Site: *siteSlug, Domain: *domain, Title: *pageName} + if err := emit(ctx, engine, *output, source, info, handler, renderer.Mode(*mode)); err != nil { + return err + } + return writeTrace(*trace, recorder) +} + +// cliPageVars resolves the page being rendered to a real row when there is one, +// so %%this|x%% answers with that page rather than staying put. +func cliPageVars(ctx context.Context, conn *db.DB, loc *i18n.Localizer, current *db.Site, category, name string) (*page.Vars, *db.Article, error) { + ref := name + if category != db.DefaultCategory { + ref = category + ":" + name + } + article, err := conn.ArticleByName(ctx, current.ID, ref) + if errors.Is(err, db.ErrNotFound) { + return nil, nil, nil + } + if err != nil { + return nil, nil, err + } + return page.NewVars(article, nil, repo.NewVarSource(ctx, conn, current), loc), article, nil +} + +func readSource(file string) (string, error) { + if file == "" { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(data), nil + } + data, err := os.ReadFile(file) + if err != nil { + return "", err + } + return string(data), nil +} + +func emit(ctx context.Context, engine renderer.Renderer, output, source string, info renderer.PageInfo, cb renderer.Callbacks, mode renderer.Mode) error { + switch output { + case "html": + result, err := engine.RenderHTML(ctx, source, info, cb, mode) + if err != nil { + return err + } + fmt.Println(result.Body) + case "text": + result, err := engine.RenderText(ctx, source, info, cb, mode) + if err != nil { + return err + } + fmt.Println(result.Body) + case "backlinks": + result, err := engine.CollectBacklinks(ctx, source, info, cb, mode) + if err != nil { + return err + } + printList("included", result.IncludedPages) + printList("linked", result.LinkedPages) + case "code": + parts, err := engine.CollectCodeAndHTML(ctx, source, info, cb, mode) + if err != nil { + return err + } + for _, block := range parts.Code { + fmt.Printf("--- code (%s) ---\n%s\n", block.Language, block.Source) + } + for _, block := range parts.HTML { + fmt.Printf("--- html ---\n%s\n", block) + } + default: + return fmt.Errorf("unknown output %q", output) + } + return nil +} + +func printList(label string, values []string) { + for _, value := range values { + fmt.Printf("%s\t%s\n", label, value) + } +} + +func writeTrace(target string, recorder *tracer) error { + if recorder == nil { + return nil + } + body := strings.Join(recorder.lines, "\n") + "\n" + if target == "-" { + _, err := io.WriteString(os.Stderr, body) + return err + } + return os.WriteFile(target, []byte(body), 0o644) +} + +type cliRepository struct { + data *repo.Repository + stub bool +} + +var _ callbacks.Repository = cliRepository{} + +// Stubbing prints what a module was handed, which is the only way to see the +// arguments the renderer dropped before they ever reached it. +func (r cliRepository) RenderModule(pc *page.Context, name string, params map[string]string, body string) (string, error) { + if r.data != nil && !r.stub { + return r.data.RenderModule(pc, name, params, body) + } + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sort.Strings(keys) + pairs := make([]string, 0, len(keys)) + for _, key := range keys { + pairs = append(pairs, key+"="+params[key]) + } + return `
[` + name + " " + strings.Join(pairs, " ") + `]
`, nil +} + +func (r cliRepository) RenderUser(username string, avatar bool) (string, error) { + if r.data == nil { + return `[` + username + `]`, nil + } + return r.data.RenderUser(username, avatar) +} + +func (r cliRepository) PageInfo(refs []string) ([]renderer.PartialPageInfo, error) { + if r.data == nil { + return nil, nil + } + return r.data.PageInfo(refs) +} + +func (r cliRepository) IncludeSources(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) { + if r.data == nil { + out := make([]renderer.FetchedPage, 0, len(refs)) + for _, ref := range refs { + out = append(out, renderer.FetchedPage{FullName: ref.FullName}) + } + return out, nil + } + return r.data.IncludeSources(refs) +} diff --git a/cmd/pwikit/runas_unix.go b/cmd/pwikit/runas_unix.go new file mode 100644 index 00000000..bd5dbe4a --- /dev/null +++ b/cmd/pwikit/runas_unix.go @@ -0,0 +1,65 @@ +//go:build unix + +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "os/user" + "strconv" + "syscall" + + "github.com/WikitTeam/ProjectWikit/internal/update" +) + +// The bundled PostgreSQL lets in only the account that owns the data +// directory, so root hands every step that touches the database to that account. +func ownerCredential(root string) (*syscall.SysProcAttr, []string) { + if os.Geteuid() != 0 { + return nil, nil + } + uid, gid, ok := update.Owner(root) + if !ok || uid == 0 { + return nil, nil + } + env := os.Environ() + if u, err := user.LookupId(strconv.FormatUint(uint64(uid), 10)); err == nil { + env = append(env, "HOME="+u.HomeDir, "USER="+u.Username, "LOGNAME="+u.Username) + } + return &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uid, Gid: gid}}, env +} + +func runOwner(ctx context.Context, root, exe string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = root + cmd.SysProcAttr, cmd.Env = ownerCredential(root) + return cmd.CombinedOutput() +} + +func asOwner(root string) (bool, error) { + attr, env := ownerCredential(root) + if attr == nil { + return false, nil + } + exe, err := runningExecutable() + if err != nil { + return true, err + } + cmd := exec.Command(exe, os.Args[1:]...) + cmd.Dir = root + cmd.SysProcAttr, cmd.Env = attr, env + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + err = cmd.Run() + var exit *exec.ExitError + if errors.As(err, &exit) { + os.Exit(exit.ExitCode()) + } + return true, err +} + +func processAlive(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} diff --git a/cmd/pwikit/runas_windows.go b/cmd/pwikit/runas_windows.go new file mode 100644 index 00000000..32fcf1f4 --- /dev/null +++ b/cmd/pwikit/runas_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package main + +import ( + "context" + "os/exec" + + "golang.org/x/sys/windows" +) + +func runOwner(ctx context.Context, root, exe string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, exe, args...) + cmd.Dir = root + return cmd.CombinedOutput() +} + +func asOwner(string) (bool, error) { return false, nil } + +func processAlive(pid int) bool { + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(h) + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return false + } + return code == 259 +} diff --git a/cmd/pwikit/serveflags.go b/cmd/pwikit/serveflags.go new file mode 100644 index 00000000..079fd802 --- /dev/null +++ b/cmd/pwikit/serveflags.go @@ -0,0 +1,260 @@ +package main + +import ( + "flag" + "fmt" + "net" + "os" + "slices" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/entry" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/update" +) + +type serveOptions struct { + fs *flag.FlagSet + listen *string + dataDir *string + trusted *string + staticDir *string + database *string + secret *string + sidecar *string + noMigrate *bool + uploadLimit *string + storageLimit *string + tlsMode *string + tlsListen *string + tlsCert *string + tlsKey *string + acmeEmail *string + acmeDirectory *string + logFile *string + dev *bool + + updateAuto *bool + updateBanner *bool + updateCheck *bool + updateWindow *string + updateMinAge *string + updateMirror *string + + automatic bool +} + +func newServeOptions() *serveOptions { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + return &serveOptions{ + fs: fs, + listen: fs.String("listen", defaultListen, "listen address; "+defaultTLSPlain+" once HTTPS is on"), + dataDir: fs.String("data-dir", "", "state directory; defaults to the directory holding the executable"), + trusted: fs.String("trusted-proxies", "", "trusted reverse proxy addresses or CIDRs, comma separated; empty trusts no X-Forwarded-* header"), + staticDir: fs.String("static-dir", "", "directory holding the frontend asset bundle"), + database: fs.String("database", "", "PostgreSQL connection string; empty runs the bundled PostgreSQL"), + secret: fs.String("secret-key", "", "key the session cookie is signed with; defaults to one pwikit makes and keeps in secrets/"+sessionKeyFile), + sidecar: fs.String("sidecar", "", "path to the ftml sidecar binary; without it the linked-in ftml is used"), + noMigrate: fs.Bool("no-migrate", false, "start without applying pending schema migrations"), + uploadLimit: fs.String("upload-limit", "0", "size the files still attached to pages may reach, such as 4GB; 0 for no ceiling"), + storageLimit: fs.String("storage-limit", "0", "size every file on disk may reach, deleted ones counted; 0 for no ceiling"), + tlsMode: fs.String("tls", string(entry.Off), "off to serve plain HTTP behind a proxy, file to use a supplied certificate, auto to obtain one over ACME; left unset, auto once a site is bound to a public domain"), + tlsListen: fs.String("tls-listen", defaultTLSAddr, "listen address for HTTPS"), + tlsCert: fs.String("tls-cert", "", "certificate chain in PEM form, for -tls=file"), + tlsKey: fs.String("tls-key", "", "private key in PEM form, for -tls=file"), + acmeEmail: fs.String("acme-email", "", "address the certificate authority sends expiry warnings to"), + acmeDirectory: fs.String("acme-directory", "", "ACME directory URL; empty uses Let's Encrypt"), + logFile: fs.String("log-file", "", "file log lines are appended to; empty writes them to standard error"), + dev: fs.Bool("dev", false, "development mode; plain HTTP that only this machine can reach, whatever the sites and settings say; -listen 9000 picks another port"), + updateAuto: fs.Bool("update-auto", true, "install new releases by themselves when pwikit runs as a system service"), + updateBanner: fs.Bool("update-public-banner", true, "announce an automatic update to every visitor, not only to the people who can open the admin panel"), + updateCheck: fs.Bool("update-check", true, "look for new releases at all"), + updateWindow: fs.String("update-window", update.DefaultWindow, "hours, in this machine's time zone, in which releases are looked for and installed"), + updateMinAge: fs.String("update-min-age", update.DefaultMinAge.String(), "how long a release must have been out before it is installed by itself"), + updateMirror: fs.String("update-mirror", "", "mirror to download releases from when GitHub cannot be reached"), + } +} + +func (o *serveOptions) updateSettings(cfg config.File) (update.Settings, error) { + boolean := func(name, env string, file *bool) (bool, error) { + fileValue := "" + if file != nil { + fileValue = strconv.FormatBool(*file) + } + raw := setting(o.fs, name, env, fileValue, o.fs.Lookup(name).DefValue) + value, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("-%s %q is not true or false", name, raw) + } + return value, nil + } + var s update.Settings + var err error + if s.Auto, err = boolean("update-auto", envUpdateAuto, cfg.Update.Auto); err != nil { + return s, err + } + if s.PublicBanner, err = boolean("update-public-banner", envUpdateBanner, cfg.Update.PublicBanner); err != nil { + return s, err + } + if s.Check, err = boolean("update-check", envUpdateCheck, cfg.Update.Check); err != nil { + return s, err + } + window := setting(o.fs, "update-window", envUpdateWindow, cfg.Update.Window, update.DefaultWindow) + if s.Window, err = update.ParseWindow(window); err != nil { + return s, err + } + age := setting(o.fs, "update-min-age", envUpdateMinAge, cfg.Update.MinAge, update.DefaultMinAge.String()) + if s.MinAge, err = time.ParseDuration(age); err != nil || s.MinAge < 0 { + return s, fmt.Errorf("update min age %q is not a duration such as 24h", age) + } + s.Mirror = setting(o.fs, "update-mirror", envUpdateMirror, cfg.Update.Mirror, "") + return s, nil +} + +func (o *serveOptions) resolve(cfg config.File) (entry.Mode, error) { + if *o.dev { + return o.resolveDev(cfg) + } + pick := func(name, env, file string) string { + return setting(o.fs, name, env, file, o.fs.Lookup(name).DefValue) + } + *o.database = pick("database", envDatabase, cfg.Database) + *o.secret = pick("secret-key", envSecretKey, "") + *o.sidecar = pick("sidecar", envSidecar, "") + *o.trusted = pick("trusted-proxies", "", strings.Join(cfg.Server.TrustedProxies, ",")) + *o.uploadLimit = pick("upload-limit", envUploadLimit, cfg.Server.UploadLimit) + *o.storageLimit = pick("storage-limit", envStorageLimit, cfg.Server.StorageLimit) + *o.tlsMode = pick("tls", envTLS, cfg.TLS.Mode) + *o.tlsListen = pick("tls-listen", envTLSListen, cfg.TLS.Listen) + *o.tlsCert = pick("tls-cert", envTLSCert, cfg.TLS.Cert) + *o.tlsKey = pick("tls-key", envTLSKey, cfg.TLS.Key) + *o.acmeEmail = pick("acme-email", envACMEEmail, cfg.TLS.ACMEEmail) + *o.acmeDirectory = pick("acme-directory", envACMEDir, cfg.TLS.ACMEDirectory) + + chosen := func(name, env, file string) bool { + return given(o.fs, name) || (env != "" && os.Getenv(env) != "") || file != "" + } + o.automatic = !chosen("tls", envTLS, cfg.TLS.Mode) && !chosen("listen", "", cfg.Server.Listen) && *o.trusted == "" + + mode, err := entry.ParseMode(*o.tlsMode) + if err != nil { + return mode, err + } + fallback := defaultListen + if mode != entry.Off { + fallback = defaultTLSPlain + } + *o.listen = setting(o.fs, "listen", "", cfg.Server.Listen, fallback) + return mode, nil +} + +func (o *serveOptions) resolveDev(cfg config.File) (entry.Mode, error) { + if given(o.fs, "tls") && *o.tlsMode != string(entry.Off) { + return "", fmt.Errorf("-dev serves plain HTTP only, so it cannot be combined with -tls=%s", *o.tlsMode) + } + *o.tlsMode = string(entry.Off) + *o.trusted = "" + if given(o.fs, "listen") { + local, err := localAddress(*o.listen) + if err != nil { + return "", err + } + *o.listen = local + } else { + *o.listen = defaultListen + } + *o.database = setting(o.fs, "database", envDatabase, cfg.Database, "") + *o.secret = setting(o.fs, "secret-key", envSecretKey, "", "") + *o.sidecar = setting(o.fs, "sidecar", envSidecar, "", "") + *o.uploadLimit = setting(o.fs, "upload-limit", envUploadLimit, cfg.Server.UploadLimit, "0") + *o.storageLimit = setting(o.fs, "storage-limit", envStorageLimit, cfg.Server.StorageLimit, "0") + return entry.Off, nil +} + +func localAddress(given string) (string, error) { + value := given + if !strings.Contains(value, ":") { + value = ":" + value + } + host, port, err := net.SplitHostPort(value) + if n, perr := strconv.Atoi(port); err != nil || perr != nil || n < 1 || n > 65535 { + return "", fmt.Errorf("-listen %q is not a port or an address with a port", given) + } + if host == "" { + host = "127.0.0.1" + } + if !loopback(host) { + return "", fmt.Errorf("-dev only listens on this machine, and -listen %q can be reached from others", given) + } + return net.JoinHostPort(host, port), nil +} + +func loopback(host string) bool { + ip := net.ParseIP(host) + return host == "localhost" || (ip != nil && ip.IsLoopback()) +} + +func (o *serveOptions) addresses(mode entry.Mode) []string { + if mode == entry.Off { + return []string{*o.listen} + } + return []string{*o.listen, *o.tlsListen} +} + +// Install cannot read the database, as the bundled PostgreSQL refuses root, so it plans for HTTPS. +func (o *serveOptions) installAddresses(mode entry.Mode) []string { + if o.automatic { + return []string{defaultTLSPlain, *o.tlsListen} + } + return o.addresses(mode) +} + +func (o *serveOptions) promote(hosts []string) (string, bool) { + if !o.automatic { + return "", false + } + for _, host := range hosts { + if site.PublicHost(host) { + *o.listen = defaultTLSPlain + return host, true + } + } + return "", false +} + +func setting(fs *flag.FlagSet, name, env, file, fallback string) string { + if given(fs, name) { + return fs.Lookup(name).Value.String() + } + if env != "" { + if value := os.Getenv(env); value != "" { + return value + } + } + if file != "" { + return file + } + return fallback +} + +func exposedPorts(addresses []string) []int { + var ports []int + for _, addr := range addresses { + host, port, err := net.SplitHostPort(addr) + if err != nil { + continue + } + if loopback(host) { + continue + } + n, err := strconv.Atoi(port) + if err != nil || n <= 0 || slices.Contains(ports, n) { + continue + } + ports = append(ports, n) + } + return ports +} diff --git a/cmd/pwikit/serveflags_test.go b/cmd/pwikit/serveflags_test.go new file mode 100644 index 00000000..2a2521a5 --- /dev/null +++ b/cmd/pwikit/serveflags_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "slices" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/entry" +) + +func resolved(t *testing.T, args []string, cfg config.File) (*serveOptions, entry.Mode) { + t.Helper() + o := newServeOptions() + if err := o.fs.Parse(args); err != nil { + t.Fatalf("Parse(%q) err = %v, want nil", args, err) + } + mode, err := o.resolve(cfg) + if err != nil { + t.Fatalf("resolve(%q) err = %v, want nil", args, err) + } + return o, mode +} + +func TestResolveFlagBeatsEnvironmentBeatsFile(t *testing.T) { + cfg := config.File{TLS: config.TLS{ACMEEmail: "file@example.com"}} + + o, _ := resolved(t, nil, cfg) + if got := *o.acmeEmail; got != "file@example.com" { + t.Errorf("acme-email with only the file = %q, want %q", got, "file@example.com") + } + + t.Setenv(envACMEEmail, "env@example.com") + o, _ = resolved(t, nil, cfg) + if got := *o.acmeEmail; got != "env@example.com" { + t.Errorf("acme-email with env and file = %q, want %q", got, "env@example.com") + } + + o, _ = resolved(t, []string{"-acme-email", "flag@example.com"}, cfg) + if got := *o.acmeEmail; got != "flag@example.com" { + t.Errorf("acme-email with flag, env and file = %q, want %q", got, "flag@example.com") + } +} + +func TestResolveFallsBackToTheFlagDefault(t *testing.T) { + o, mode := resolved(t, nil, config.File{}) + if mode != entry.Off { + t.Errorf("mode = %q, want %q", mode, entry.Off) + } + if got := *o.listen; got != defaultListen { + t.Errorf("listen = %q, want %q", got, defaultListen) + } + if got := *o.tlsListen; got != defaultTLSAddr { + t.Errorf("tls-listen = %q, want %q", got, defaultTLSAddr) + } +} + +func TestResolveListensOnPort80OnceTLSIsOn(t *testing.T) { + o, mode := resolved(t, nil, config.File{TLS: config.TLS{Mode: "auto"}}) + if mode != entry.Auto { + t.Errorf("mode = %q, want %q", mode, entry.Auto) + } + if got := *o.listen; got != defaultTLSPlain { + t.Errorf("listen = %q, want %q", got, defaultTLSPlain) + } +} + +func TestResolveKeepsAListenAddressFromTheFile(t *testing.T) { + o, _ := resolved(t, nil, config.File{TLS: config.TLS{Mode: "auto"}, Server: config.Server{Listen: ":8080"}}) + if got := *o.listen; got != ":8080" { + t.Errorf("listen = %q, want %q", got, ":8080") + } +} + +func TestResolveJoinsTrustedProxiesFromTheFile(t *testing.T) { + o, _ := resolved(t, nil, config.File{Server: config.Server{TrustedProxies: []string{"10.0.0.0/8", "127.0.0.1"}}}) + if got := *o.trusted; got != "10.0.0.0/8,127.0.0.1" { + t.Errorf("trusted-proxies = %q, want %q", got, "10.0.0.0/8,127.0.0.1") + } +} + +func TestResolveRefusesAnUnknownTLSMode(t *testing.T) { + o := newServeOptions() + if _, err := o.resolve(config.File{TLS: config.TLS{Mode: "maybe"}}); err == nil { + t.Error("resolve(tls.mode = maybe) err = nil, want an error") + } +} + +func TestResolveIsAutomaticWhenNothingChoseHowPwikitIsReached(t *testing.T) { + cases := []struct { + name string + args []string + cfg config.File + want bool + }{ + {"nothing set", nil, config.File{}, true}, + {"tls in the file", nil, config.File{TLS: config.TLS{Mode: "off"}}, false}, + {"listen on the command line", []string{"-listen", ":8080"}, config.File{}, false}, + {"listen in the file", nil, config.File{Server: config.Server{Listen: ":8080"}}, false}, + {"trusted proxies", nil, config.File{Server: config.Server{TrustedProxies: []string{"127.0.0.1"}}}, false}, + {"only an acme email", nil, config.File{TLS: config.TLS{ACMEEmail: "you@example.com"}}, true}, + } + for _, c := range cases { + o, _ := resolved(t, c.args, c.cfg) + if o.automatic != c.want { + t.Errorf("resolve(%s).automatic = %t, want %t", c.name, o.automatic, c.want) + } + } +} + +func TestResolveAutomaticIsOffWithTLSFromTheEnvironment(t *testing.T) { + t.Setenv(envTLS, "off") + if o, _ := resolved(t, nil, config.File{}); o.automatic { + t.Errorf("resolve with PWIKIT_TLS=off .automatic = true, want false") + } +} + +func TestPromoteTurnsHTTPSOnForAPublicDomain(t *testing.T) { + o, _ := resolved(t, nil, config.File{}) + domain, ok := o.promote([]string{"localhost", "wiki.scp-wiki.cn"}) + if !ok || domain != "wiki.scp-wiki.cn" { + t.Fatalf("promote() = %q, %t, want wiki.scp-wiki.cn, true", domain, ok) + } + if *o.listen != defaultTLSPlain { + t.Errorf("listen after promote = %q, want %q", *o.listen, defaultTLSPlain) + } +} + +func TestPromoteLeavesALocalSiteOnPlainHTTP(t *testing.T) { + o, _ := resolved(t, nil, config.File{}) + if _, ok := o.promote([]string{"localhost", "localhost:8080", "wiki.test"}); ok { + t.Error("promote(local hosts) = true, want false") + } + if *o.listen != defaultListen { + t.Errorf("listen = %q, want %q", *o.listen, defaultListen) + } +} + +func TestPromoteRespectsAnExplicitChoice(t *testing.T) { + o, _ := resolved(t, nil, config.File{TLS: config.TLS{Mode: "off"}}) + if _, ok := o.promote([]string{"wiki.scp-wiki.cn"}); ok { + t.Error("promote() with tls.mode = off = true, want false") + } +} + +func TestInstallAddressesPlanForHTTPSWhenAutomatic(t *testing.T) { + o, mode := resolved(t, nil, config.File{}) + if got := exposedPorts(o.installAddresses(mode)); !slices.Equal(got, []int{80, 443}) { + t.Errorf("exposedPorts(installAddresses) = %v, want [80 443]", got) + } + o, mode = resolved(t, []string{"-listen", "127.0.0.1:8080"}, config.File{}) + if got := exposedPorts(o.installAddresses(mode)); got != nil { + t.Errorf("exposedPorts(installAddresses) behind a proxy = %v, want none", got) + } +} + +func TestDevListensOnlyOnThisMachine(t *testing.T) { + cfg := config.File{TLS: config.TLS{Mode: "auto"}, Server: config.Server{Listen: ":80", TrustedProxies: []string{"10.0.0.0/8"}}} + o, mode := resolved(t, []string{"-dev"}, cfg) + if mode != entry.Off { + t.Errorf("mode with -dev = %q, want %q", mode, entry.Off) + } + if *o.listen != defaultListen { + t.Errorf("listen with -dev = %q, want %q", *o.listen, defaultListen) + } + if *o.trusted != "" { + t.Errorf("trusted-proxies with -dev = %q, want empty", *o.trusted) + } + if o.automatic { + t.Error("automatic with -dev = true, want false") + } + if _, ok := o.promote([]string{"wiki.scp-wiki.cn"}); ok { + t.Error("promote() with -dev = true, want false") + } + if got := exposedPorts(o.installAddresses(mode)); got != nil { + t.Errorf("exposedPorts(installAddresses) with -dev = %v, want none", got) + } +} + +func TestDevIgnoresTLSFromTheEnvironment(t *testing.T) { + t.Setenv(envTLS, "auto") + if _, mode := resolved(t, []string{"-dev"}, config.File{}); mode != entry.Off { + t.Errorf("mode with -dev and PWIKIT_TLS=auto = %q, want %q", mode, entry.Off) + } +} + +func TestDevAcceptsAnotherLocalPort(t *testing.T) { + cases := map[string]string{ + "9000": "127.0.0.1:9000", + ":9000": "127.0.0.1:9000", + "127.0.0.1:9000": "127.0.0.1:9000", + "localhost:9000": "localhost:9000", + "[::1]:9000": "[::1]:9000", + } + for given, want := range cases { + o, _ := resolved(t, []string{"-dev", "-listen", given}, config.File{}) + if *o.listen != want { + t.Errorf("listen with -dev -listen %s = %q, want %q", given, *o.listen, want) + } + } +} + +func TestDevRefusesWhatOthersCouldReach(t *testing.T) { + cases := [][]string{ + {"-dev", "-listen", "0.0.0.0:8080"}, + {"-dev", "-listen", "192.0.2.7:8080"}, + {"-dev", "-listen", "70000"}, + {"-dev", "-listen", "web"}, + {"-dev", "-tls", "auto"}, + } + for _, args := range cases { + o := newServeOptions() + if err := o.fs.Parse(args); err != nil { + t.Fatalf("Parse(%q) err = %v, want nil", args, err) + } + if _, err := o.resolve(config.File{}); err == nil { + t.Errorf("resolve(%q) err = nil, want an error", args) + } + } +} + +func TestDevStillReadsTheDatabaseFromTheFile(t *testing.T) { + o, _ := resolved(t, []string{"-dev"}, config.File{Database: "postgres://dev@127.0.0.1/wiki"}) + if *o.database != "postgres://dev@127.0.0.1/wiki" { + t.Errorf("database with -dev = %q, want the file's", *o.database) + } +} + +func TestExposedPortsSkipsLoopback(t *testing.T) { + cases := []struct { + addresses []string + want []int + }{ + {[]string{"127.0.0.1:8080"}, nil}, + {[]string{"localhost:8080", "[::1]:8443"}, nil}, + {[]string{":80", ":443"}, []int{80, 443}}, + {[]string{"0.0.0.0:8080", "192.0.2.7:8080"}, []int{8080}}, + {[]string{"not an address"}, nil}, + } + for _, c := range cases { + if got := exposedPorts(c.addresses); !slices.Equal(got, c.want) { + t.Errorf("exposedPorts(%q) = %v, want %v", c.addresses, got, c.want) + } + } +} + +func TestMailConfigReadsTheFileUnderTheEnvironment(t *testing.T) { + yes := true + file := config.Mail{Host: "smtp.file.test", Port: 587, UseTLS: &yes, From: "wiki@file.test"} + + got := mailConfig(file) + if got.Host != "smtp.file.test" || got.Port != "587" || !got.UseTLS || got.From != "wiki@file.test" { + t.Errorf("mailConfig(file) = %+v, want host, port 587, TLS and from taken from the file", got) + } + + t.Setenv(envMailHost, "smtp.env.test") + t.Setenv(envMailTLS, "false") + got = mailConfig(file) + if got.Host != "smtp.env.test" { + t.Errorf("mailConfig(file).Host with env = %q, want %q", got.Host, "smtp.env.test") + } + if got.UseTLS { + t.Errorf("mailConfig(file).UseTLS with EMAIL_USE_TLS=false = true, want false") + } +} + +func TestMailConfigConsoleSendsNothing(t *testing.T) { + if got := mailConfig(config.Mail{Engine: config.EngineConsole, Host: "smtp.file.test"}); got.Host != "" { + t.Errorf("mailConfig(console).Host = %q, want empty", got.Host) + } +} + +func TestMailConfigDefaultPort(t *testing.T) { + if got := mailConfig(config.Mail{}).Port; got != defaultMailPort { + t.Errorf("mailConfig(empty).Port = %q, want %q", got, defaultMailPort) + } +} diff --git a/cmd/pwikit/service.go b/cmd/pwikit/service.go new file mode 100644 index 00000000..34b549c7 --- /dev/null +++ b/cmd/pwikit/service.go @@ -0,0 +1,164 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/service" +) + +func serviceUsage() { + fmt.Fprint(os.Stderr, `Usage: pwikit service [options] [-- serve options] + + install start pwikit whenever the machine boots, and start it now + uninstall stop pwikit and no longer start it at boot + start start the installed service + stop stop the installed service + status show whether the installed service is running + print show what install would register, without registering it + +Options: + -name name the service is registered under; defaults to pwikit + -user account the service runs as on Linux and macOS; defaults to the one sudo was run from + -data-dir state directory; defaults to the directory holding the executable + -no-path leave the PATH alone; by default install makes pwikit runnable by name + from any directory and uninstall undoes it + +Anything after -- is handed to pwikit serve, for example: + pwikit service install -- -tls=auto -acme-email you@example.com + +On Linux and on a Mac that should start it at boot, run install with sudo. +On Windows, run it from a terminal opened with Run as administrator. +`) +} + +func serviceCommand(args []string) error { + sub := "" + if len(args) > 0 { + sub = args[0] + } + switch sub { + case "install", "uninstall", "start", "stop", "status", "print": + default: + serviceUsage() + return errors.New("unknown service subcommand") + } + + fs := flag.NewFlagSet("service "+sub, flag.ContinueOnError) + name := fs.String("name", service.DefaultName, "name the service is registered under") + account := fs.String("user", "", "account the service runs as on Linux and macOS") + dataDir := fs.String("data-dir", "", "state directory; defaults to the directory holding the executable") + noPath := fs.Bool("no-path", false, "leave the PATH alone on install and uninstall") + if err := fs.Parse(args[1:]); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if err := service.ValidName(*name); err != nil { + return err + } + + switch sub { + case "uninstall": + if err := service.Uninstall(*name); err != nil { + return err + } + if !*noPath { + if exe, err := runnableExecutable(); err == nil { + servicePath(exe, false) + } + } + return nil + case "start": + return service.Start(*name) + case "stop": + return service.Stop(*name) + case "status": + return service.Status(*name) + } + + spec, err := serviceSpec(*name, *account, *dataDir, fs.Args()) + if err != nil { + return err + } + if sub == "print" { + text, err := service.Preview(spec) + if err != nil { + return err + } + fmt.Print(text) + return nil + } + if err := service.Install(spec); err != nil { + return err + } + fmt.Printf("installed %s; it starts now and whenever the machine boots\n", spec.Name) + fmt.Println(afterInstall(spec)) + if !*noPath { + servicePath(spec.Executable, true) + } + return nil +} + +func serviceSpec(name, account, dataDir string, extra []string) (service.Spec, error) { + p, err := paths.New(dataDir) + if err != nil { + return service.Spec{}, err + } + if p.Source() == paths.SourceGoRun { + return service.Spec{}, errors.New("go run leaves the program in a temporary directory; build pwikit and install that instead") + } + exe, err := os.Executable() + if err != nil { + return service.Spec{}, err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + + args := []string{"serve", "-data-dir", p.Root()} + spec := service.Spec{Name: name, Executable: exe, Root: p.Root(), User: account} + if !inContainer() { + spec.UpdateArgs = append([]string{"update", "-auto", "-service", name, "-data-dir", p.Root(), "--"}, extra...) + } + if runtime.GOOS != "linux" { + spec.LogFile = filepath.Join(p.Logs(), "pwikit.log") + args = append(args, "-log-file", spec.LogFile) + } + if runtime.GOOS == "darwin" { + spec.CrashFile = filepath.Join(p.Logs(), "pwikit-stderr.log") + } + spec.Args = append(args, extra...) + + opts := newServeOptions() + if err := opts.fs.Parse(extra); err != nil { + return service.Spec{}, fmt.Errorf("options for serve: %w", err) + } + if opts.fs.NArg() > 0 { + return service.Spec{}, fmt.Errorf("options for serve: unexpected %q", opts.fs.Arg(0)) + } + cfg, err := config.Load(p.Config()) + if err != nil { + return service.Spec{}, err + } + mode, err := opts.resolve(cfg) + if err != nil { + return service.Spec{}, err + } + spec.Ports = exposedPorts(opts.installAddresses(mode)) + return spec, nil +} + +func afterInstall(spec service.Spec) string { + if runtime.GOOS == "linux" { + return " follow its log with: journalctl -u " + spec.Name + " -f" + } + return " its log is " + spec.LogFile +} diff --git a/cmd/pwikit/trace.go b/cmd/pwikit/trace.go new file mode 100644 index 00000000..3545f6e1 --- /dev/null +++ b/cmd/pwikit/trace.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/renderer" +) + +type tracer struct { + inner renderer.Callbacks + lines []string +} + +var _ renderer.Callbacks = (*tracer)(nil) + +func (t *tracer) log(format string, args ...any) { + t.lines = append(t.lines, fmt.Sprintf(format, args...)) +} + +func (t *tracer) ModuleHasBody(name string) (bool, error) { + t.log("module_has_body(%s)", name) + return t.inner.ModuleHasBody(name) +} + +func (t *tracer) ModuleIsInline(name string) (bool, error) { + t.log("module_is_inline(%s)", name) + return t.inner.ModuleIsInline(name) +} + +func (t *tracer) RenderModule(name string, params map[string]string, body string) (string, error) { + t.log("render_module(%s, {%s}, body=%q)", name, joinPairs(params), body) + return t.inner.RenderModule(name, params, body) +} + +func (t *tracer) RenderUser(user string, avatar bool) (string, error) { + t.log("render_user(%s, avatar=%t)", user, avatar) + return t.inner.RenderUser(user, avatar) +} + +func (t *tracer) GetI18nMessage(id string) (string, error) { + t.log("get_i18n_message(%s)", id) + return t.inner.GetI18nMessage(id) +} + +func (t *tracer) GetHTMLInjectedCode(id string) (string, error) { + t.log("get_html_injected_code(%s)", id) + return t.inner.GetHTMLInjectedCode(id) +} + +func (t *tracer) GetPageInfo(refs []string) ([]renderer.PartialPageInfo, error) { + t.log("get_page_info([%s])", strings.Join(refs, " ")) + return t.inner.GetPageInfo(refs) +} + +func (t *tracer) EvaluateExpression(expr string) (renderer.ExpressionResult, error) { + t.log("evaluate_expression(%q)", expr) + return t.inner.EvaluateExpression(expr) +} + +func (t *tracer) NormalizePageName(fullName string) (string, error) { + t.log("normalize_page_name(%q)", fullName) + return t.inner.NormalizePageName(fullName) +} + +func (t *tracer) IncludePages(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) { + parts := make([]string, 0, len(refs)) + for _, ref := range refs { + parts = append(parts, fmt.Sprintf("%s{%s}", ref.FullName, joinPairs(ref.Variables))) + } + t.log("include_pages([%s])", strings.Join(parts, " ")) + return t.inner.IncludePages(refs) +} + +func (t *tracer) NoSuchInclude(fullName string) (string, error) { + t.log("no_such_include(%s)", fullName) + return t.inner.NoSuchInclude(fullName) +} + +func (t *tracer) NextIncludeLevel() (bool, error) { + t.log("next_include_level()") + return t.inner.NextIncludeLevel() +} + +func joinPairs(values map[string]string) string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + pairs := make([]string, 0, len(keys)) + for _, key := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%q", key, values[key])) + } + return strings.Join(pairs, ", ") +} diff --git a/cmd/pwikit/update.go b/cmd/pwikit/update.go new file mode 100644 index 00000000..51deadd5 --- /dev/null +++ b/cmd/pwikit/update.go @@ -0,0 +1,789 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "math/rand/v2" + "net/http" + "os" + "path/filepath" + "runtime" + "slices" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/config" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/entry" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/mail" + "github.com/WikitTeam/ProjectWikit/internal/migrate" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/pgbundle" + "github.com/WikitTeam/ProjectWikit/internal/service" + "github.com/WikitTeam/ProjectWikit/internal/static" + "github.com/WikitTeam/ProjectWikit/internal/update" + "github.com/WikitTeam/ProjectWikit/internal/version" + staticfiles "github.com/WikitTeam/ProjectWikit/static" +) + +func updateUsage() { + fmt.Fprint(os.Stderr, `Usage: pwikit update [check|status|rollback|unpin|postpone|skip] [options] [-- serve options] + + (none) install the newest release now + check say whether a newer release exists + status show what the updater knows and has planned + rollback put back the release, and the data, from before the last update + unpin let automatic updates run again after pwikit update -to held a release + postpone put off the scheduled automatic update by 24 hours + skip never install the release that is scheduled or newest automatically + +Options: + -to release to install instead of the newest; an older one is held until unpin + -service name of the installed service; defaults to pwikit + -data-dir state directory; defaults to the directory holding the executable + -mirror mirror to download from when GitHub cannot be reached + -yes answer yes to the rollback question + +Options after -- are the ones the service was installed with, such as -database. +An instance installed as a system service is updated with sudo on Linux and macOS +and from a terminal opened with Run as administrator on Windows. +`) +} + +type updateFlags struct { + fs *flag.FlagSet + to *string + name *string + dataDir *string + mirror *string + yes *bool + auto *bool + + outcome *string + from *string + target *string + kind *string + errText *string + notify *bool + pin *string +} + +func newUpdateFlags(sub string) *updateFlags { + fs := flag.NewFlagSet("update "+sub, flag.ContinueOnError) + fs.Usage = updateUsage + return &updateFlags{ + fs: fs, + to: fs.String("to", "", "release to install instead of the newest"), + name: fs.String("service", service.DefaultName, "name of the installed service"), + dataDir: fs.String("data-dir", "", "state directory; defaults to the directory holding the executable"), + mirror: fs.String("mirror", "", "mirror to download from when GitHub cannot be reached"), + yes: fs.Bool("yes", false, "answer yes to the rollback question"), + auto: fs.Bool("auto", false, "run as the scheduled update task"), + outcome: fs.String("outcome", "", "what the update came to"), + from: fs.String("from", "", "release updated from"), + target: fs.String("target", "", "release updated to"), + kind: fs.String("kind", "", "kind of rollback point"), + errText: fs.String("error", "", "why the update failed"), + notify: fs.Bool("notify", false, "mail the superusers"), + pin: fs.String("pin", "", "release to hold"), + } +} + +func updateCommand(args []string) error { + sub := "" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + sub, args = args[0], args[1:] + } + serveArgs := []string{} + if i := slices.Index(args, "--"); i >= 0 { + serveArgs, args = args[i+1:], args[:i] + } + f := newUpdateFlags(sub) + if err := f.fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if f.fs.NArg() > 0 { + updateUsage() + return fmt.Errorf("unexpected %q", f.fs.Arg(0)) + } + p, err := paths.New(*f.dataDir) + if err != nil { + return err + } + + switch sub { + case "": + if *f.auto { + return autoUpdate(p, *f.name, serveArgs) + } + return manualUpdate(p, f, serveArgs) + case "rollback": + return rollbackUpdate(p, f, serveArgs) + case "check": + return checkUpdate(p, f, serveArgs) + case "status", "unpin", "postpone", "skip": + if handled, err := asOwner(p.Root()); handled || err != nil { + return err + } + return editUpdateState(p, sub, serveArgs) + case "tick": + return tickUpdate(p, serveArgs) + case "preflight": + return preflightUpdate(p, serveArgs) + case "record": + return recordUpdate(p, f, serveArgs) + } + updateUsage() + return fmt.Errorf("unknown update subcommand %q", sub) +} + +type instanceSettings struct { + opts *serveOptions + cfg config.File + settings update.Settings +} + +func loadInstance(p *paths.Paths, serveArgs []string, mirror string) (instanceSettings, error) { + o := newServeOptions() + if err := o.fs.Parse(serveArgs); err != nil { + return instanceSettings{}, fmt.Errorf("options after --: %w", err) + } + cfg, err := config.Load(p.Config()) + if err != nil { + return instanceSettings{}, err + } + if _, err := o.resolve(cfg); err != nil { + return instanceSettings{}, err + } + s, err := o.updateSettings(cfg) + if err != nil { + return instanceSettings{}, err + } + if mirror != "" { + s.Mirror = mirror + } + return instanceSettings{opts: o, cfg: cfg, settings: s}, nil +} + +func (in instanceSettings) databaseArgs() []string { + if *in.opts.database == "" { + return nil + } + return []string{"-database", *in.opts.database} +} + +func (in instanceSettings) bundledPostgres() string { + if *in.opts.database == "" { + return pgbundle.Version + } + return "" +} + +type updateLog struct { + file *os.File + out io.Writer +} + +func openUpdateLog(p *paths.Paths) *updateLog { + l := &updateLog{out: os.Stdout} + if err := os.MkdirAll(p.Logs(), 0o755); err == nil { + if f, err := os.OpenFile(filepath.Join(p.Logs(), "update.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil { + l.file = f + l.out = io.MultiWriter(os.Stdout, f) + } + } + return l +} + +func (l *updateLog) logf(format string, args ...any) { + fmt.Fprintf(l.out, "%s %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} + +func (l *updateLog) close(p *paths.Paths) { + if l.file != nil { + l.file.Close() + update.ChownTree(l.file.Name(), p.Root()) + } +} + +func runningExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return exe, nil +} + +func autoUpdate(p *paths.Paths, name string, serveArgs []string) error { + running, err := service.Running(name) + if err != nil { + return err + } + if !running { + return nil + } + exe, err := runningExecutable() + if err != nil { + return err + } + out, err := runOwner(context.Background(), p.Root(), exe, append([]string{"update", "tick", "-data-dir", p.Root(), "--"}, serveArgs...)...) + if err != nil { + l := openUpdateLog(p) + defer l.close(p) + l.logf("the scheduled check failed: %v: %s", err, strings.TrimSpace(string(out))) + return err + } + var tick struct { + Apply string `json:"apply"` + } + if err := json.Unmarshal(lastJSONLine(out), &tick); err != nil { + return fmt.Errorf("read the scheduled check: %w", err) + } + if tick.Apply == "" { + return nil + } + return installRelease(p, name, serveArgs, tick.Apply, "", true) +} + +func manualUpdate(p *paths.Paths, f *updateFlags, serveArgs []string) error { + in, err := loadInstance(p, serveArgs, *f.mirror) + if err != nil { + return err + } + target := *f.to + if target != "" && !strings.HasPrefix(target, "v") { + target = "v" + target + } + if target == "" { + m, err := update.NewSource(in.settings.Mirror).Latest(context.Background()) + if err != nil { + return err + } + target = m.Version + } + if target == version.String() { + fmt.Printf("pwikit %s is already running\n", target) + return nil + } + pin := "" + if *f.to != "" && !update.Newer(target, version.String()) { + pin = target + } + return installRelease(p, *f.name, serveArgs, target, pin, false) +} + +func installRelease(p *paths.Paths, name string, serveArgs []string, target, pin string, auto bool) error { + l := openUpdateLog(p) + defer l.close(p) + unlock, err := update.Lock(p.Updates()) + if err != nil { + return err + } + defer unlock() + + in, err := loadInstance(p, serveArgs, "") + if err != nil { + return err + } + exe, err := runningExecutable() + if err != nil { + return err + } + running, err := service.Running(name) + if err != nil && !auto { + running = false + } + if !running && serveAlive(p) { + return errors.New("pwikit serve is running in the foreground; stop it first, or update an instance installed with pwikit service install") + } + + ctx := context.Background() + src := update.NewSource(in.settings.Mirror) + src.Log = func(line string) { l.logf("%s", line) } + sums, err := src.Checksums(ctx, target) + if err != nil { + return fmt.Errorf("read the checksums of %s: %w", target, err) + } + file := update.PackageFile(target, runtime.GOOS, runtime.GOARCH) + sum, ok := sums[file] + if !ok { + return fmt.Errorf("release %s has no package for %s/%s", target, runtime.GOOS, runtime.GOARCH) + } + + l.logf("updating %s from %s to %s", p.Root(), version.String(), target) + applier := &update.Applier{ + Root: p.Root(), + PGData: p.PGData(), + Executable: exe, + Bundled: in.bundledPostgres() != "", + Current: version.String(), + Database: in.databaseArgs(), + Source: src, + Machine: serviceMachine{name: name, stopped: !running}, + Run: func(ctx context.Context, exe string, args ...string) ([]byte, error) { + out, err := runOwner(ctx, p.Root(), exe, args...) + if len(out) > 0 { + l.logf("%s", strings.TrimSpace(string(out))) + } + return out, err + }, + Maintenance: func(ctx context.Context) (func(), error) { return maintenance(p) }, + Logf: l.logf, + } + if !running { + applier.Maintenance = nil + applier.Offline = true + } + outcome := applier.Apply(ctx, update.Target{Version: target, File: file, SHA256: sum, AllowPostgresMove: !auto}) + + record := []string{"update", "record", "-data-dir", p.Root(), "-from", outcome.From, "-target", outcome.To, "-kind", outcome.Kind} + switch { + case outcome.Err == nil: + record = append(record, "-outcome", update.OutcomeUpdated) + if pin != "" { + record = append(record, "-pin", pin) + } + case outcome.RolledBack: + record = append(record, "-outcome", update.OutcomeRolledBack, "-error", outcome.Err.Error(), "-notify") + default: + record = append(record, "-outcome", update.OutcomeFailed, "-error", outcome.Err.Error()) + } + if running || outcome.Err == nil { + if out, err := runOwner(ctx, p.Root(), exe, append(append(record, "--"), serveArgs...)...); err != nil { + l.logf("could not record the outcome: %v: %s", err, strings.TrimSpace(string(out))) + } + } + + if outcome.Err == nil { + if !running { + fmt.Printf("installed %s; start pwikit serve again\n", target) + } + return nil + } + if outcome.RollbackErr != nil { + return fmt.Errorf("update to %s failed: %v; rolling back failed as well: %v. See %s", + target, outcome.Err, outcome.RollbackErr, filepath.Join(p.Logs(), "update.log")) + } + if outcome.RolledBack { + return fmt.Errorf("update to %s failed and %s was put back: %v", target, outcome.From, outcome.Err) + } + return fmt.Errorf("update to %s failed: %v", target, outcome.Err) +} + +func rollbackUpdate(p *paths.Paths, f *updateFlags, serveArgs []string) error { + point, err := update.ReadRollback(update.RollbackDir(p.Root())) + if err != nil { + return err + } + if point.Expired(time.Now()) { + return fmt.Errorf("the rollback point from %s is older than seven days and is no longer used", point.CreatedAt.Format(time.RFC3339)) + } + if point.From == version.String() { + return fmt.Errorf("pwikit %s is already running", point.From) + } + if point.Kind != update.RollbackNone && !*f.yes { + fmt.Printf("This puts back pwikit %s and the database as it was at %s.\n", point.From, point.CreatedAt.Local().Format("2006-01-02 15:04")) + fmt.Print("Everything written to the site since then is lost. Continue? [y/N] ") + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return errors.New("nothing was changed") + } + } + + l := openUpdateLog(p) + defer l.close(p) + unlock, err := update.Lock(p.Updates()) + if err != nil { + return err + } + defer unlock() + in, err := loadInstance(p, serveArgs, "") + if err != nil { + return err + } + exe, err := runningExecutable() + if err != nil { + return err + } + running, _ := service.Running(*f.name) + if !running && serveAlive(p) { + return errors.New("pwikit serve is running in the foreground; stop it first") + } + applier := &update.Applier{ + Root: p.Root(), PGData: p.PGData(), Executable: exe, + Bundled: in.bundledPostgres() != "", Current: version.String(), Database: in.databaseArgs(), + Machine: serviceMachine{name: *f.name, stopped: !running}, + Run: func(ctx context.Context, exe string, args ...string) ([]byte, error) { + return runOwner(ctx, p.Root(), exe, args...) + }, + Maintenance: func(ctx context.Context) (func(), error) { return maintenance(p) }, + Logf: l.logf, + } + if !running { + applier.Maintenance = nil + applier.Offline = true + } + if _, err := applier.Rollback(context.Background()); err != nil { + return err + } + record := []string{"update", "record", "-data-dir", p.Root(), "-outcome", "manual-rollback", + "-from", point.To, "-target", point.From, "-pin", point.From, "--"} + if out, err := runOwner(context.Background(), p.Root(), exe, append(record, serveArgs...)...); err != nil { + l.logf("could not record the rollback: %v: %s", err, strings.TrimSpace(string(out))) + } + fmt.Printf("put back pwikit %s; automatic updates stay off until pwikit update unpin\n", point.From) + return nil +} + +func checkUpdate(p *paths.Paths, f *updateFlags, serveArgs []string) error { + in, err := loadInstance(p, serveArgs, *f.mirror) + if err != nil { + return err + } + src := update.NewSource(in.settings.Mirror) + src.Log = func(line string) { fmt.Fprintln(os.Stderr, line) } + m, err := src.Latest(context.Background()) + if err != nil { + return err + } + current := version.String() + fmt.Printf("running %s\n", current) + fmt.Printf("newest %s, released %s\n", m.Version, m.Published().Local().Format("2006-01-02 15:04")) + if !update.Newer(m.Version, current) { + fmt.Println("nothing newer to install") + return nil + } + if update.PostgresMajor(m.Postgres) != update.PostgresMajor(pgbundle.Version) && in.bundledPostgres() != "" { + fmt.Printf("it moves the bundled PostgreSQL to version %s, which takes a backup and a restore\n", update.PostgresMajor(m.Postgres)) + } + fmt.Printf("notes %s\n", m.Notes) + fmt.Println("install it with: pwikit update") + return nil +} + +func tickUpdate(p *paths.Paths, serveArgs []string) error { + in, err := loadInstance(p, serveArgs, "") + if err != nil { + return err + } + ctx := context.Background() + conn, release, err := openInstanceDB(ctx, p, in) + if err != nil { + return err + } + defer release() + st, err := conn.UpdateState(ctx) + if err != nil { + return err + } + now := time.Now() + if st.RollbackExpiresAt != nil && now.After(*st.RollbackExpiresAt) { + os.RemoveAll(update.RollbackDir(p.Root())) + st.RollbackVersion, st.RollbackKind, st.RollbackExpiresAt = "", "", nil + } + src := update.NewSource(in.settings.Mirror) + facts := update.Facts{Current: version.String(), BundledPostgres: in.bundledPostgres(), Container: inContainer(), Now: now} + apply := update.Tick(&st, in.settings, facts, func() (update.Manifest, error) { return src.Latest(ctx) }, + rand.New(rand.NewPCG(uint64(now.UnixNano()), uint64(os.Getpid())))) + if err := conn.SaveUpdateState(ctx, st); err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]string{"apply": apply}) +} + +func preflightUpdate(p *paths.Paths, serveArgs []string) error { + pre := update.Preflight{Version: version.String(), Postgres: pgbundle.Version} + defer func() { json.NewEncoder(os.Stdout).Encode(pre) }() + + in, err := loadInstance(p, serveArgs, "") + if err != nil { + pre.Problem = err.Error() + return nil + } + if in.bundledPostgres() != "" { + if have, err := os.ReadFile(filepath.Join(p.PGData(), "PG_VERSION")); err == nil { + pre.PostgresMoves = strings.TrimSpace(string(have)) != update.PostgresMajor(pgbundle.Version) + } + } + ctx := context.Background() + dsn, release, err := resolveDatabase(ctx, *in.opts.database, p.Root()) + if err != nil { + pre.Problem = err.Error() + return nil + } + defer release() + state, err := migrate.Status(ctx, dsn) + if err != nil { + pre.Problem = err.Error() + return nil + } + pre.Pending = state.Pending + pre.PendingBreaking = state.PendingBreaking() + switch { + case len(state.UnknownBreaking) > 0: + pre.Problem = (&migrate.NewerSchemaError{Migrations: state.UnknownBreaking, AppliedBy: state.AppliedBy}).Error() + case len(state.Unknown) > 0 && len(state.Pending) > 0: + pre.Problem = fmt.Sprintf("the database holds %s from another release while this one still has %s to apply", + strings.Join(state.Unknown, ", "), strings.Join(state.Pending, ", ")) + } + return nil +} + +func recordUpdate(p *paths.Paths, f *updateFlags, serveArgs []string) error { + in, err := loadInstance(p, serveArgs, "") + if err != nil { + return err + } + ctx := context.Background() + conn, release, err := openInstanceDB(ctx, p, in) + if err != nil { + return err + } + defer release() + st, err := conn.UpdateState(ctx) + if err != nil { + return err + } + now := time.Now().UTC() + st.LastOutcome, st.LastFrom, st.LastTo, st.LastError, st.LastAt = *f.outcome, *f.from, *f.target, *f.errText, &now + switch *f.outcome { + case update.OutcomeUpdated: + expires := now.Add(update.RollbackKept) + st.RollbackVersion, st.RollbackKind, st.RollbackExpiresAt = *f.from, *f.kind, &expires + st.ScheduledVersion, st.ScheduledAt = "", nil + st.PinnedVersion = "" + case update.OutcomeRolledBack: + if !slices.Contains(st.FailedVersions, *f.target) { + st.FailedVersions = append(st.FailedVersions, *f.target) + } + st.ScheduledVersion, st.ScheduledAt = "", nil + st.RollbackVersion, st.RollbackKind, st.RollbackExpiresAt = "", "", nil + case "manual-rollback": + st.RollbackVersion, st.RollbackKind, st.RollbackExpiresAt = "", "", nil + st.ScheduledVersion, st.ScheduledAt = "", nil + } + if *f.pin != "" { + st.PinnedVersion = *f.pin + } + if err := conn.SaveUpdateState(ctx, st); err != nil { + return err + } + if *f.notify { + notifyRollback(ctx, p, in, conn, st) + } + return nil +} + +func notifyRollback(ctx context.Context, p *paths.Paths, in instanceSettings, conn *db.DB, st db.UpdateState) { + to, err := conn.SuperuserEmails(ctx) + if err != nil || len(to) == 0 { + return + } + bundle, err := i18n.Load(p.Locales()) + if err != nil { + return + } + loc := bundle.Localizer(i18n.DefaultLanguage) + at := "" + if st.LastAt != nil { + at = st.LastAt.Local().Format("2006-01-02 15:04 MST") + } + subject := loc.T("update.mail-rolled-back-subject", "version", st.LastTo, "from", st.LastFrom) + body := loc.T("update.mail-rolled-back-body", "root", p.Root(), "time", at, "from", st.LastFrom, + "version", st.LastTo, "error", st.LastError, "log", filepath.Join(p.Logs(), "update.log")) + if err := mail.New(mailConfig(in.cfg.Mail)).Send(ctx, to, subject, body); err != nil { + fmt.Fprintf(os.Stderr, "could not mail the superusers about the rollback: %v\n", err) + } +} + +func editUpdateState(p *paths.Paths, sub string, serveArgs []string) error { + in, err := loadInstance(p, serveArgs, "") + if err != nil { + return err + } + ctx := context.Background() + conn, release, err := openInstanceDB(ctx, p, in) + if err != nil { + return err + } + defer release() + st, err := conn.UpdateState(ctx) + if err != nil { + return err + } + now := time.Now() + switch sub { + case "status": + printUpdateStatus(st, in, now) + return nil + case "unpin": + if st.PinnedVersion == "" { + fmt.Println("no release is held") + return nil + } + fmt.Printf("released the hold on %s\n", st.PinnedVersion) + st.PinnedVersion = "" + case "postpone": + update.Postpone(&st, now) + fmt.Printf("automatic updates wait until %s\n", st.PostponedUntil.Local().Format("2006-01-02 15:04")) + case "skip": + if skipped := update.Skip(&st); skipped != "" { + fmt.Printf("%s will not be installed automatically\n", skipped) + } + } + return conn.SaveUpdateState(ctx, st) +} + +func printUpdateStatus(st db.UpdateState, in instanceSettings, now time.Time) { + stamp := func(at *time.Time) string { + if at == nil { + return "never" + } + return at.Local().Format("2006-01-02 15:04") + } + facts := update.Facts{Current: version.String(), BundledPostgres: in.bundledPostgres(), Container: inContainer(), Now: now} + fmt.Printf("running %s\n", version.String()) + fmt.Printf("automatic %t, window %s, releases older than %s\n", in.settings.Auto, in.settings.Window, in.settings.MinAge) + fmt.Printf("checked %s\n", stamp(st.CheckedAt)) + if st.CheckError != "" { + fmt.Printf("check error %s\n", st.CheckError) + } + fmt.Printf("next check %s\n", stamp(st.NextCheckAt)) + if st.LatestVersion != "" { + fmt.Printf("newest %s, released %s\n", st.LatestVersion, stamp(st.LatestPublishedAt)) + } + if st.ScheduledVersion != "" { + fmt.Printf("scheduled %s at %s\n", st.ScheduledVersion, stamp(st.ScheduledAt)) + } else if _, reason := update.Eligible(st, in.settings, facts); reason.Code != "" { + fmt.Printf("not planned %s\n", reason) + } + if st.PinnedVersion != "" { + fmt.Printf("held at %s\n", st.PinnedVersion) + } + if st.LastOutcome != "" { + fmt.Printf("last update %s from %s to %s at %s\n", st.LastOutcome, st.LastFrom, st.LastTo, stamp(st.LastAt)) + if st.LastError != "" { + fmt.Printf("last error %s\n", st.LastError) + } + } + if st.RollbackVersion != "" { + fmt.Printf("rollback to %s, kept until %s\n", st.RollbackVersion, stamp(st.RollbackExpiresAt)) + } +} + +func openInstanceDB(ctx context.Context, p *paths.Paths, in instanceSettings) (*db.DB, func(), error) { + dsn, release, err := resolveDatabase(ctx, *in.opts.database, p.Root()) + if err != nil { + return nil, nil, err + } + conn, err := db.Open(ctx, dsn) + if err != nil { + release() + return nil, nil, err + } + return conn, func() { conn.Close(); release() }, nil +} + +type serviceMachine struct { + name string + stopped bool +} + +func (m serviceMachine) StopService(context.Context) error { + if m.stopped { + return nil + } + return service.Stop(m.name) +} + +func (m serviceMachine) StartService(context.Context) error { + if m.stopped { + return nil + } + return service.Start(m.name) +} + +func maintenance(p *paths.Paths) (func(), error) { + rt, err := update.ReadRuntime(p.Updates()) + if err != nil { + return nil, err + } + bundle, err := i18n.Load(p.Locales()) + if err != nil { + return nil, err + } + var assets *static.Assets + var files http.Handler + if staticfiles.Embedded { + assets = static.NewAssets(staticfiles.Files) + files = static.New(staticfiles.Files, http.NotFoundHandler()) + } else { + assets = static.NewAssets(nil) + } + allowed := rt.Hosts + cfg := entry.Config{ + Mode: entry.Mode(rt.Mode), + Plain: rt.Plain, + Secure: rt.Secure, + CertFile: rt.CertFile, + KeyFile: rt.KeyFile, + CacheDir: rt.CacheDir, + Email: rt.Email, + Directory: rt.Directory, + Hosts: func(_ context.Context, host string) error { + if slices.Contains(allowed, host) { + return nil + } + return fmt.Errorf("host %q is not a site on this server", host) + }, + Handler: update.MaintenanceHandler(bundle, assets, files), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- entry.Serve(ctx, cfg) }() + select { + case err := <-done: + cancel() + return nil, err + case <-time.After(500 * time.Millisecond): + } + return func() { + cancel() + <-done + if rt.CacheDir != "" { + update.ChownTree(rt.CacheDir, p.Root()) + } + }, nil +} + +func serveAlive(p *paths.Paths) bool { + rt, err := update.ReadRuntime(p.Updates()) + if err != nil || rt.PID == 0 || rt.PID == os.Getpid() { + return false + } + return processAlive(rt.PID) +} + +func lastJSONLine(out []byte) []byte { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if line := strings.TrimSpace(lines[i]); strings.HasPrefix(line, "{") { + return []byte(line) + } + } + return nil +} diff --git a/cmd/pwikit/version.go b/cmd/pwikit/version.go new file mode 100644 index 00000000..c54a394b --- /dev/null +++ b/cmd/pwikit/version.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "runtime" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/pgbundle" + "github.com/WikitTeam/ProjectWikit/internal/version" +) + +func printVersion() error { + fmt.Printf("pwikit %s\n", version.String()) + if commit := version.Commit(); commit != "" { + fmt.Printf("commit %s\n", commit) + } + if at := version.BuiltFrom(); !at.IsZero() { + fmt.Printf("committed %s\n", at.UTC().Format(time.RFC3339)) + } + fmt.Printf("platform %s/%s\n", runtime.GOOS, runtime.GOARCH) + if pgbundle.Embedded() { + fmt.Printf("postgresql %s, bundled\n", pgbundle.Version) + } else { + fmt.Println("postgresql not bundled") + } + fmt.Printf("go %s\n", runtime.Version()) + return nil +} diff --git a/cmd/pwikit/winservice_other.go b/cmd/pwikit/winservice_other.go new file mode 100644 index 00000000..54243a89 --- /dev/null +++ b/cmd/pwikit/winservice_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +func runAsService([]string) (bool, error) { + return false, nil +} diff --git a/cmd/pwikit/winservice_windows.go b/cmd/pwikit/winservice_windows.go new file mode 100644 index 00000000..9ecb9e5c --- /dev/null +++ b/cmd/pwikit/winservice_windows.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "errors" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + + "github.com/WikitTeam/ProjectWikit/internal/service" +) + +func runAsService(args []string) (bool, error) { + isService, err := svc.IsWindowsService() + if err != nil || !isService { + return false, err + } + return true, svc.Run(service.DefaultName, &windowsService{args: args}) +} + +type windowsService struct { + args []string +} + +func (w *windowsService) Execute(_ []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + status <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + if len(w.args) == 0 || w.args[0] != "serve" { + done <- errors.New("a pwikit service can only run pwikit serve") + return + } + done <- serve(ctx, w.args[1:]) + }() + + accepts := svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPreShutdown + status <- svc.Status{State: svc.Running, Accepts: accepts} + for { + select { + case err := <-done: + if err != nil { + report(err) + // A nonzero exit code is what makes the recovery actions restart the service. + return true, 1 + } + return false, 0 + case r := <-requests: + switch r.Cmd { + case svc.Interrogate: + status <- r.CurrentStatus + case svc.Stop, svc.Shutdown, svc.PreShutdown: + status <- svc.Status{State: svc.StopPending, WaitHint: uint32(service.StopTimeout * 1000)} + cancel() + } + } + } +} + +func report(err error) { + log, openErr := eventlog.Open(service.EventSource) + if openErr != nil { + return + } + defer log.Close() + log.Error(1, "pwikit stopped: "+err.Error()) +} diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index b41855f8..00000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,68 +0,0 @@ -name: ${COMPOSE_PROJECT_NAME:-wikitgo} -services: - web: - restart: unless-stopped - build: . - depends_on: - postgres: - condition: service_healthy - ports: - - ${WEB_PORT:-8000}:8000 - volumes: - - ./files:/app/files - - ./archive:/app/archive:ro - - update-mailbox:/mailbox - env_file: - - .env - environment: - DB_ENGINE: pg - DB_PG_HOST: postgres - UPDATE_MAILBOX: /mailbox - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - postgres: - image: postgres:14 - restart: unless-stopped - volumes: - - ./postgresql:/var/lib/postgresql - - ./postgresql/data:/var/lib/postgresql/data - environment: - POSTGRES_USER: ${DB_PG_USERNAME:-admin} - POSTGRES_PASSWORD: ${DB_PG_PASSWORD:-wikitpassword} - POSTGRES_DB: ${DB_PG_DATABASE:-projwikit} - healthcheck: - test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] - interval: 5s - timeout: 3s - retries: 3 - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - updater: - build: ./updater - restart: unless-stopped - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - "${HOST_PROJECT_DIR:?set HOST_PROJECT_DIR in .env}:${HOST_PROJECT_DIR}" - - update-mailbox:/mailbox - working_dir: ${HOST_PROJECT_DIR} - environment: - HOST_PROJECT_DIR: ${HOST_PROJECT_DIR} - COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-wikitgo} - UPDATE_REPO: ${UPDATE_REPO:-WikitTeam/ProjectWikit} - UPDATE_BRANCH: ${UPDATE_BRANCH:-master} - UPDATE_POLL_INTERVAL: ${UPDATE_POLL_INTERVAL:-600} - MAILBOX: /mailbox - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" - -volumes: - update-mailbox: diff --git a/docker/compose.yaml b/docker/compose.yaml new file mode 100644 index 00000000..fda22bc6 --- /dev/null +++ b/docker/compose.yaml @@ -0,0 +1,54 @@ +name: pwikit + +services: + password: + image: postgres:18 + entrypoint: ["sh", "-c"] + command: + - | + if [ ! -s /run/pwikit/db-password ]; then + umask 022 + head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > /run/pwikit/db-password + fi + volumes: + - secrets:/run/pwikit + restart: "no" + + postgres: + image: postgres:18 + restart: unless-stopped + depends_on: + password: + condition: service_completed_successfully + environment: + POSTGRES_USER: pwikit + POSTGRES_DB: pwikit + POSTGRES_PASSWORD_FILE: /run/pwikit/db-password + volumes: + - pgdata:/var/lib/postgresql + - secrets:/run/pwikit:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pwikit -d pwikit"] + interval: 5s + timeout: 3s + retries: 20 + + pwikit: + image: ${PWIKIT_IMAGE:-ghcr.io/wikitteam/pwikit:latest} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgres://pwikit@postgres:5432/pwikit?sslmode=disable + PWIKIT_DATABASE_PASSWORD_FILE: /run/pwikit/db-password + volumes: + - data:/data + - secrets:/run/pwikit:ro + ports: + - "${PWIKIT_PORT:-8080}:8080" + +volumes: + data: + pgdata: + secrets: diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 9e9dde31..00000000 --- a/entrypoint.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -echo Starting migrations... -python manage.py migrate - -echo Starting server... -exec gunicorn wikitgo.wsgi -w 32 -t 300 -b 0.0.0.0:8000 --preload \ No newline at end of file diff --git a/files/.gitkeep b/files/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/web/js/.prettierrc b/frontend/.prettierrc similarity index 100% rename from web/js/.prettierrc rename to frontend/.prettierrc diff --git a/frontend/api/articles.ts b/frontend/api/articles.ts new file mode 100644 index 00000000..68cd64fb --- /dev/null +++ b/frontend/api/articles.ts @@ -0,0 +1,116 @@ +import { wFetch } from '../util/fetch-util' +import { ModuleRateVotesResponse, RatingMode } from './rate' +import { UserData } from './user' + +export interface ArticleData { + pageId: string + title?: string + source?: string + tags?: string[] + authors?: UserData[] + parent?: string + locked?: boolean +} + +export interface ArticleUpdateRequest extends ArticleData { + authorsIds?: number[] + forcePageId?: boolean +} + +export async function createArticle(data: ArticleData) { + await wFetch(`/pw-api/articles/new`, { method: 'POST', sendJson: true, body: data }) +} + +export interface FullArticleRating { + value: number + mode: RatingMode + votes: number + popularity: number +} + +export interface FullArticleData { + uid: number + pageId: string + title: string + canonicalUrl: string + createdAt: string + updatedAt: string + createdBy: UserData + authors: UserData[] + updatedBy: UserData + rating: FullArticleRating + tags: string[] +} + +export function fetchAllArticles(): Promise { + return wFetch('/pw-api/articles') +} + +export function fetchArticle(pageId: string): Promise { + return wFetch(`/pw-api/articles/${pageId}`) +} + +export async function updateArticle(pageId: string, data: ArticleUpdateRequest): Promise { + return wFetch(`/pw-api/articles/${pageId}`, { method: 'PUT', sendJson: true, body: data }) +} + +export async function deleteArticle(pageId: string) { + await wFetch(`/pw-api/articles/${pageId}`, { method: 'DELETE', sendJson: true }) +} + +export interface ArticleLogEntry { + revNumber: number + user: UserData + comment: string + defaultComment: string + createdAt: string + type: string + meta: Record +} + +export interface ArticleLog { + count: number + entries: Array +} + +export async function fetchArticleLog(pageId: string, from: number = 0, to: number = from + 25): Promise { + return await wFetch(`/pw-api/articles/${pageId}/log?from=${from}&to=${to}`) +} + +export async function revertArticleRevision(pageId: string, revNumber: number): Promise { + return await wFetch(`/pw-api/articles/${pageId}/log`, { method: 'PUT', sendJson: true, body: { revNumber: revNumber } }) +} + +export interface ArticleVersion { + source: string + rendered: string +} + +export async function fetchArticleVersion(pageId: string, revNum: number, pathParams?: { [key: string]: string }): Promise { + const urlPathParams = pathParams && `&pathParams=${JSON.stringify(pathParams ?? {})}` + return await wFetch(`/pw-api/articles/${pageId}/version?revNum=${revNum}${urlPathParams}`) +} + +export interface ArticleBacklink { + id: string + title: string + exists: boolean +} + +export interface ArticleBacklinks { + children: Array + includes: Array + links: Array +} + +export async function fetchArticleBacklinks(pageId: string): Promise { + return await wFetch(`/pw-api/articles/${pageId}/links`) +} + +export async function fetchArticleVotes(pageId: string): Promise { + return await wFetch(`/pw-api/articles/${pageId}/votes`) +} + +export async function deleteArticleVotes(pageId: string): Promise { + return await wFetch(`/pw-api/articles/${pageId}/votes`, { method: 'DELETE' }) +} diff --git a/frontend/api/favourites.ts b/frontend/api/favourites.ts new file mode 100644 index 00000000..61c0491a --- /dev/null +++ b/frontend/api/favourites.ts @@ -0,0 +1,37 @@ +import { callModule } from './modules' +import { wFetch } from '../util/fetch-util' + +export interface FavouriteState { + pageId: string + favourites: number + favourited: boolean +} + +export async function favouriteArticle(pageId: string) { + return await callModule({ module: 'rate', method: 'favourite', pageId }) +} + +export async function unfavouriteArticle(pageId: string) { + return await callModule({ module: 'rate', method: 'unfavourite', pageId }) +} + +export async function fetchFavouriteState(pageId: string) { + return await callModule({ module: 'rate', method: 'get_favourites', pageId }) +} + +export interface FavouriteEntry { + pageId: string + title: string + addedAt: string +} + +export interface FavouriteListing { + page: number + pages: number + total: number + favourites: Array +} + +export async function getFavourites(page: number) { + return await wFetch(`/pw-api/favourites?page=${page}`) +} diff --git a/web/js/api/files.ts b/frontend/api/files.ts similarity index 78% rename from web/js/api/files.ts rename to frontend/api/files.ts index d984252c..f945614a 100644 --- a/web/js/api/files.ts +++ b/frontend/api/files.ts @@ -20,11 +20,11 @@ export interface ArticleFiles { } export async function fetchArticleFiles(pageId: string): Promise { - return await wFetch(`/api/articles/${pageId}/files`) + return await wFetch(`/pw-api/articles/${pageId}/files`) } export async function uploadFile(pageId: string, file: File, fileName: string, uploadProgressHandler?: WRequestUploadProgressHandler) { - return await wFetch(`/api/articles/${pageId}/files`, { + return await wFetch(`/pw-api/articles/${pageId}/files`, { method: 'POST', backend: 'xhr', uploadProgressHandler, @@ -36,7 +36,7 @@ export async function uploadFile(pageId: string, file: File, fileName: string, u } export async function renameFile(fileId: number, newFileName: string) { - return await wFetch(`/api/files/${encodeURIComponent(fileId)}`, { + return await wFetch(`/pw-api/files/${encodeURIComponent(fileId)}`, { method: 'PUT', sendJson: true, body: { name: newFileName }, @@ -44,7 +44,7 @@ export async function renameFile(fileId: number, newFileName: string) { } export async function deleteFile(fileId: number) { - return await wFetch(`/api/files/${encodeURIComponent(fileId)}`, { + return await wFetch(`/pw-api/files/${encodeURIComponent(fileId)}`, { method: 'DELETE', }) } diff --git a/web/js/api/forum.ts b/frontend/api/forum.ts similarity index 100% rename from web/js/api/forum.ts rename to frontend/api/forum.ts diff --git a/frontend/api/likes.ts b/frontend/api/likes.ts new file mode 100644 index 00000000..72c747be --- /dev/null +++ b/frontend/api/likes.ts @@ -0,0 +1,33 @@ +import { callModule } from './modules' +import { UserData } from './user' + +export interface PostLikeState { + postId: number + count: number + liked: boolean +} + +export interface PostLikesResponse { + postId: number + count: number + page: number + pages: number + perPage: number + users: UserData[] +} + +export async function likePost(postId: number) { + return await callModule({ module: 'forumpost', method: 'like', params: { postid: postId } }) +} + +export async function unlikePost(postId: number) { + return await callModule({ module: 'forumpost', method: 'unlike', params: { postid: postId } }) +} + +export async function fetchPostLikes(postId: number, page: number) { + return await callModule({ + module: 'forumpost', + method: 'likes', + params: { postid: postId, page }, + }) +} diff --git a/web/js/api/messages.ts b/frontend/api/messages.ts similarity index 78% rename from web/js/api/messages.ts rename to frontend/api/messages.ts index f8499d6e..c7bd20af 100644 --- a/web/js/api/messages.ts +++ b/frontend/api/messages.ts @@ -37,7 +37,7 @@ export interface CanSendResponse { } export async function getConversations(): Promise { - return await wFetch('/api/messages/conversations') + return await wFetch('/pw-api/messages/conversations') } export async function getConversation( @@ -54,12 +54,12 @@ export async function getConversation( }) if (after >= 0) params.set('after', String(after)) return await wFetch( - `/api/messages/with/${partnerId}?${params.toString()}`, + `/pw-api/messages/with/${partnerId}?${params.toString()}`, ) } export async function sendMessage(recipientId: number, body: string): Promise { - return await wFetch('/api/messages/send', { + return await wFetch('/pw-api/messages/send', { method: 'POST', sendJson: true, body: { recipient_id: recipientId, body }, @@ -67,15 +67,15 @@ export async function sendMessage(recipientId: number, body: string): Promise { - return await wFetch(`/api/messages/can-send/${userId}`) + return await wFetch(`/pw-api/messages/can-send/${userId}`) } export async function blockUser(userId: number): Promise<{ status: string; blocked: boolean }> { - return await wFetch(`/api/users/${userId}/block`, { method: 'POST', sendJson: true, body: {} }) + return await wFetch(`/pw-api/users/${userId}/block`, { method: 'POST', sendJson: true, body: {} }) } export async function unblockUser(userId: number): Promise<{ status: string; blocked: boolean }> { - return await wFetch(`/api/users/${userId}/block`, { method: 'DELETE', sendJson: true, body: {} }) + return await wFetch(`/pw-api/users/${userId}/block`, { method: 'DELETE', sendJson: true, body: {} }) } export async function reportMessages( @@ -83,7 +83,7 @@ export async function reportMessages( messageIds: number[], reason: string, ): Promise<{ status: string; report_id: number }> { - return await wFetch('/api/messages/report', { + return await wFetch('/pw-api/messages/report', { method: 'POST', sendJson: true, body: { reported_id: reportedId, message_ids: messageIds, reason }, diff --git a/web/js/api/modules.ts b/frontend/api/modules.ts similarity index 78% rename from web/js/api/modules.ts rename to frontend/api/modules.ts index 4ab637ef..fcfac39f 100644 --- a/web/js/api/modules.ts +++ b/frontend/api/modules.ts @@ -14,5 +14,5 @@ export interface ModuleRenderResponse { } export async function callModule(request: ModuleRequest) { - return await wFetch(`/api/modules`, { method: 'POST', sendJson: true, body: request }) + return await wFetch(`/pw-api/modules`, { method: 'POST', sendJson: true, body: request }) } diff --git a/frontend/api/notifications.ts b/frontend/api/notifications.ts new file mode 100644 index 00000000..2ce97f8d --- /dev/null +++ b/frontend/api/notifications.ts @@ -0,0 +1,146 @@ +import { UserData } from '~api/user' +import { wFetch } from '../util/fetch-util' + +interface NotificationEntity { + id: number + name: string + url: string +} + +interface BaseNotification { + id: number + created_at: string + is_viewed: boolean +} + +interface NotificationWelcome extends BaseNotification { + type: 'welcome' +} + +interface NotificationNewArticleRevision extends BaseNotification { + type: 'new_article_revision' + user: UserData + article: { + uid: number + pageId: string + title: string + } + rev_id: number + rev_number: number + rev_type: string + rev_meta: Record + comment: string +} + +interface NotificationNewThreadPost extends BaseNotification { + type: 'new_thread_post' + author: UserData + section: NotificationEntity + category: NotificationEntity + thread: NotificationEntity + post: NotificationEntity + message: string +} + +interface NotificationNewPostReply extends BaseNotification { + type: 'new_post_reply' + author: UserData + section: NotificationEntity + category: NotificationEntity + thread: NotificationEntity + post: NotificationEntity + origin: NotificationEntity + message: string +} + +interface NotificationForumMention extends BaseNotification { + type: 'forum_mention' + author: UserData + section: NotificationEntity + category: NotificationEntity + thread: NotificationEntity + post: NotificationEntity + message: string +} + +interface NotificationDirectMessage extends BaseNotification { + type: 'direct_message' + sender_id: number + sender_name: string + message_id: number + preview: string +} + +interface NotificationPostLike extends BaseNotification { + type: 'post_like' + author: UserData + thread: NotificationEntity + post: NotificationEntity +} + +export type Notification = + | NotificationNewPostReply + | NotificationNewThreadPost + | NotificationWelcome + | NotificationNewArticleRevision + | NotificationForumMention + | NotificationDirectMessage + | NotificationPostLike + +export interface NotificationsResponse { + cursor: number + notifications: Notification[] +} + +export interface NotificationSubscriptionData { + pageId?: string + forumThreadId?: number +} + +export interface NotificationSubscriptionResponse { + status?: string +} + +export type NotificationKind = 'all' | 'post_like' | 'replies' | 'direct_message' + +// The reply tab covers both shapes a forum answer can arrive as, so one tab +// does not leave half of them behind. +const KIND_QUERY: Record = { + all: '', + post_like: 'post_like', + replies: 'new_post_reply,new_thread_post', + direct_message: 'direct_message', +} + +export async function getNotifications( + cursor: number, + limit: number = 10, + unread: boolean = false, + mark_viewed: boolean = false, + kind: NotificationKind = 'all', +) { + const type = KIND_QUERY[kind] ? `&type=${KIND_QUERY[kind]}` : '' + return await wFetch( + `/pw-api/notifications?cursor=${cursor}&limit=${limit}&unread=${unread}&mark_as_viewed=${mark_viewed}${type}`, + ) +} + +export async function clearNotifications(ids: number[]) { + return await wFetch<{ removed: number }>(`/pw-api/notifications`, { method: 'DELETE', sendJson: true, body: { ids } }) +} + +export async function clearAllNotifications(kind: NotificationKind) { + return await wFetch<{ removed: number }>(`/pw-api/notifications`, { + method: 'DELETE', + sendJson: true, + body: { all: true, type: KIND_QUERY[kind] || undefined }, + }) +} + +export async function subscribeToNotifications(data: NotificationSubscriptionData) { + return await wFetch(`/pw-api/notifications/subscribe`, { method: 'POST', sendJson: true, body: data }) +} + +export async function unsubscribeFromNotifications(data: NotificationSubscriptionData) { + return await wFetch(`/pw-api/notifications/subscribe`, { method: 'DELETE', sendJson: true, body: data }) +} diff --git a/frontend/api/own-lists.ts b/frontend/api/own-lists.ts new file mode 100644 index 00000000..1637c130 --- /dev/null +++ b/frontend/api/own-lists.ts @@ -0,0 +1,38 @@ +import { wFetch } from '../util/fetch-util' + +export interface RatingEntry { + pageId: string + title: string + rate: number + votedAt: string | null +} + +export interface RatingListing { + page: number + pages: number + total: number + ratings: Array +} + +export interface LikedPostEntry { + postId: number + name: string + threadName: string + url: string + likedAt: string +} + +export interface LikedPostListing { + page: number + pages: number + total: number + posts: Array +} + +export async function getOwnRatings(page: number) { + return await wFetch(`/pw-api/ratings?page=${page}`) +} + +export async function getOwnLikedPosts(page: number) { + return await wFetch(`/pw-api/liked-posts?page=${page}`) +} diff --git a/web/js/api/preview.ts b/frontend/api/preview.ts similarity index 75% rename from web/js/api/preview.ts rename to frontend/api/preview.ts index ce078c9a..12a1aa14 100644 --- a/web/js/api/preview.ts +++ b/frontend/api/preview.ts @@ -14,5 +14,5 @@ export interface PreviewResponse { } export function makePreview(data: PreviewData) { - return wFetch(`/api/preview`, { method: 'POST', sendJson: true, body: data }) + return wFetch(`/pw-api/preview`, { method: 'POST', sendJson: true, body: data }) } diff --git a/web/js/api/rate.ts b/frontend/api/rate.ts similarity index 100% rename from web/js/api/rate.ts rename to frontend/api/rate.ts diff --git a/web/js/api/search-module.ts b/frontend/api/search-module.ts similarity index 97% rename from web/js/api/search-module.ts rename to frontend/api/search-module.ts index 62bb6ef7..0e1bae5f 100644 --- a/web/js/api/search-module.ts +++ b/frontend/api/search-module.ts @@ -4,6 +4,7 @@ export interface SearchModuleParams { q?: string author?: string tags?: string + category?: string datefrom?: string dateto?: string offset?: number diff --git a/web/js/api/tags.ts b/frontend/api/tags.ts similarity index 100% rename from web/js/api/tags.ts rename to frontend/api/tags.ts diff --git a/web/js/api/user.ts b/frontend/api/user.ts similarity index 76% rename from web/js/api/user.ts rename to frontend/api/user.ts index 3db12918..319e79bf 100644 --- a/web/js/api/user.ts +++ b/frontend/api/user.ts @@ -15,11 +15,11 @@ export interface UserData { } export function fetchAllUsers(): Promise { - return wFetch('/api/users') + return wFetch('/pw-api/users') } export function lookupUser(username: string): Promise { - return wFetch(`/api/users/lookup?username=${encodeURIComponent(username)}`) + return wFetch(`/pw-api/users/lookup?username=${encodeURIComponent(username)}`) } export interface AdminSusUser { @@ -31,5 +31,5 @@ export interface AdminSusUser { } export function fetchAdminSusUsers(): Promise { - return wFetch('/api/admin/sus') + return wFetch('/pw-api/admin/sus') } diff --git a/web/js/articles/article-authorship.tsx b/frontend/articles/article-authorship.tsx similarity index 81% rename from web/js/articles/article-authorship.tsx rename to frontend/articles/article-authorship.tsx index 51edb27a..31dba399 100644 --- a/web/js/articles/article-authorship.tsx +++ b/frontend/articles/article-authorship.tsx @@ -6,6 +6,7 @@ import { fetchAllUsers, UserData } from '../api/user' import AuthorshipEditorComponent from '../components/authorship-editor' import sleep from '../util/async-sleep' import useConstCallback from '../util/const-callback' +import { t } from '~util/i18n' import Loader from '../util/loader' import WikidotModal from '../util/wikidot-modal' @@ -20,20 +21,11 @@ interface Props { const Styles = styled.div` .text { &.loading { - &::after { - content: ' '; + .loader { position: absolute; - background: #0000003f; - z-index: 0; left: 0; right: 0; top: 0; - bottom: 0; - } - .loader { - position: absolute; - left: 16px; - top: 16px; z-index: 1; } } @@ -79,7 +71,7 @@ const ArticleAuthorship: React.FC = ({ user, pageId, editable, onClose }) }) .catch(e => { setFatalError(true) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) }) .finally(() => { setLoading(false) @@ -88,7 +80,7 @@ const ArticleAuthorship: React.FC = ({ user, pageId, editable, onClose }) const onAskSubmit = useConstCallback(async () => { if (authors.length == 0) { - setError('必须至少指定一个作者') + setError(t('articles.authorship.needs-one-author')) return } if (user && originAuthors.includes(user) && !authors.includes(user)) { @@ -118,7 +110,7 @@ const ArticleAuthorship: React.FC = ({ user, pageId, editable, onClose }) window.location.reload() } catch (e) { setFatalError(false) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) } finally { setSaving(false) } @@ -159,44 +151,44 @@ const ArticleAuthorship: React.FC = ({ user, pageId, editable, onClose }) {saving && ( -

保存中...

+

{t('articles.authorship.saving')}

)} {savingSuccess && ( -

保存成功!

+

{t('articles.authorship.saved')}

)} {error && ( - +

- 错误: {error} + {t('articles.authorship.error-label')} {error}

)} {askTransferOwnership && ( -

是否放弃页面作者身份?

+

{t('articles.authorship.disown-title')}

- 请注意,只有该页面作者栏中指定的人或管理员才能将作者权归还给您 + {t('articles.authorship.disown-note')}

)} - 关闭 + {t('articles.authorship.close')} -

页面作者信息

+

{t('articles.authorship.title')}

- +
作者:{t('articles.authorship.author-label')}
@@ -208,9 +200,9 @@ const ArticleAuthorship: React.FC = ({ user, pageId, editable, onClose })
{editable && (
- - - + + +
)}
diff --git a/web/js/articles/article-backlinks.tsx b/frontend/articles/article-backlinks.tsx similarity index 81% rename from web/js/articles/article-backlinks.tsx rename to frontend/articles/article-backlinks.tsx index 921f6454..292e4b08 100644 --- a/web/js/articles/article-backlinks.tsx +++ b/frontend/articles/article-backlinks.tsx @@ -1,3 +1,4 @@ +import { t } from '~util/i18n' import * as React from 'react' import { useEffect, useState } from 'react' import styled from 'styled-components' @@ -14,20 +15,11 @@ interface Props { const Styles = styled.div` .text { &.loading { - &::after { - content: ' '; + .loader { position: absolute; - background: #0000003f; - z-index: 0; left: 0; right: 0; top: 0; - bottom: 0; - } - .loader { - position: absolute; - left: 16px; - top: 16px; z-index: 1; } } @@ -48,7 +40,7 @@ const ArticleBacklinksView: React.FC = ({ pageId, onClose }) => { }) .catch(e => { setFatalError(true) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) }) .finally(() => { setLoading(false) @@ -73,20 +65,20 @@ const ArticleBacklinksView: React.FC = ({ pageId, onClose }) => { return ( {error && ( - +

- 错误: {error} + {t('articles.backlinks.error-label')} {error}

)} - 关闭 + {t('articles.backlinks.close')} -

依赖此页面的其他页面

+

{t('articles.backlinks.title')}

{loading && } {data?.links?.length ? ( <> -

反向链接

+

{t('articles.backlinks.links-heading')}

    {data.links.map((x, i) => (
  • @@ -100,7 +92,7 @@ const ArticleBacklinksView: React.FC = ({ pageId, onClose }) => { ) : null} {data?.includes?.length ? ( <> -

    嵌入 (使用 [[include]])

    +

    {t('articles.backlinks.includes-heading')}

      {data.includes.map((x, i) => (
    • @@ -114,7 +106,7 @@ const ArticleBacklinksView: React.FC = ({ pageId, onClose }) => { ) : null} {data?.children?.length ? ( <> -

      子页面

      +

      {t('articles.backlinks.children-heading')}

        {data.children.map((x, i) => (
      • @@ -126,7 +118,7 @@ const ArticleBacklinksView: React.FC = ({ pageId, onClose }) => {
      ) : null} - {!data?.children?.length && !data?.links?.length && !data?.includes?.length && !loading &&

      此页面没有反向链接

      } + {!data?.children?.length && !data?.links?.length && !data?.includes?.length && !loading &&

      {t('articles.backlinks.empty')}

      } ) } diff --git a/web/js/articles/article-child.tsx b/frontend/articles/article-child.tsx similarity index 77% rename from web/js/articles/article-child.tsx rename to frontend/articles/article-child.tsx index 7d822147..192b6986 100644 --- a/web/js/articles/article-child.tsx +++ b/frontend/articles/article-child.tsx @@ -1,3 +1,4 @@ +import { t } from '~util/i18n' import * as React from 'react' import { useRef, useState } from 'react' import styled from 'styled-components' @@ -13,20 +14,11 @@ interface Props { const Styles = styled.div` .text { &.loading { - &::after { - content: ' '; + .loader { position: absolute; - background: #0000003f; - z-index: 0; left: 0; right: 0; top: 0; - bottom: 0; - } - .loader { - position: absolute; - left: 16px; - top: 16px; z-index: 1; } } @@ -47,7 +39,7 @@ const ArticleChild: React.FC = ({ pageId, onClose }) => { if (isFullNameAllowed(child) && child != pageId) { window.location.href = `/${child}/edit/true/parent/${pageId}` } else { - setError('无效的子页面ID!') + setError(t('articles.child.invalid-name')) } }) @@ -81,20 +73,20 @@ const ArticleChild: React.FC = ({ pageId, onClose }) => { return ( {error && ( - +

      - 错误: {error} + {t('articles.child.error-label')} {error}

      )} - 关闭 + {t('articles.child.close')} -

      创建子页面

      -

      此操作将创建一个以此页面为父页面的新页面

      +

      {t('articles.child.title')}

      +

      {t('articles.child.note')}

      {' '} - 提示: onSnippet(e, 'fragment:')}>fragment: /{' '} + {t('articles.child.hint-label')} onSnippet(e, 'fragment:')}>fragment: /{' '} onSnippet(e, `fragment:${pageId}_`)}>{`fragment:${pageId}_`}

      @@ -102,11 +94,11 @@ const ArticleChild: React.FC = ({ pageId, onClose }) => { - + - + @@ -114,8 +106,8 @@ const ArticleChild: React.FC = ({ pageId, onClose }) => {
      此页面名称:{t('articles.child.parent-name-label')} {pageId}
      子页面名称:{t('articles.child.child-name-label')}
      - - + +
      diff --git a/web/js/articles/article-delete.tsx b/frontend/articles/article-delete.tsx similarity index 76% rename from web/js/articles/article-delete.tsx rename to frontend/articles/article-delete.tsx index 19ab5ff0..b2d56cd9 100644 --- a/web/js/articles/article-delete.tsx +++ b/frontend/articles/article-delete.tsx @@ -1,3 +1,4 @@ +import { t } from '~util/i18n' import * as React from 'react' import { useEffect, useState } from 'react' import styled from 'styled-components' @@ -16,20 +17,11 @@ interface Props { const Styles = styled.div` .text { &.loading { - &::after { - content: ' '; + .loader { position: absolute; - background: #0000003f; - z-index: 0; left: 0; right: 0; top: 0; - bottom: 0; - } - .loader { - position: absolute; - left: 16px; - top: 16px; z-index: 1; } } @@ -54,7 +46,7 @@ const ArticleDelete: React.FC = ({ pageId, onClose, canDelete, canRename }) .catch(e => { setFatalError(true) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) }) .finally(() => { setLoading(false) @@ -96,7 +88,7 @@ const ArticleDelete: React.FC = ({ pageId, onClose, canDelete, canRename } } catch (e) { setFatalError(false) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) } finally { setSaving(false) } @@ -131,10 +123,10 @@ const ArticleDelete: React.FC = ({ pageId, onClose, canDelete, canRename return ( - 关闭 + {t('articles.delete.close-blocked')} -

      删除页面

      -

      此页面已被标记为删除,无法再次删除

      +

      {t('articles.delete.title-blocked')}

      +

      {t('articles.delete.already-deleted')}

      ) } @@ -143,36 +135,36 @@ const ArticleDelete: React.FC = ({ pageId, onClose, canDelete, canRename {saving && ( -

      删除中...

      +

      {t('articles.delete.deleting')}

      )} {savingSuccess && ( -

      删除成功!

      +

      {t('articles.delete.deleted')}

      )} {error && ( - +

      - 错误: {error} + {t('articles.delete.error-label')} {error}

      )} - 关闭 + {t('articles.delete.close')} -

      删除页面

      +

      {t('articles.delete.title')}

      {canDelete ? ( -

      您可以将页面移至“deleted”分类,或永久删除(此操作不可恢复,请谨慎操作)。

      +

      {t('articles.delete.note')}

      ) : ( -

      您可以将页面移至“deleted”分类以完成删除。永久删除功能不可用。

      +

      {t('articles.delete.note-no-permanent')}

      )} {canDelete && ( - + @@ -198,7 +190,7 @@ const ArticleDelete: React.FC = ({ pageId, onClose, canDelete, canRename checked={permanent} disabled={loading || saving} /> - + @@ -208,24 +200,24 @@ const ArticleDelete: React.FC = ({ pageId, onClose, canDelete, canRename {!permanent ? (

      - 为页面添加前缀“deleted:”可将其移至其他分类(命名空间)。此操作相当于删除,但信息不会丢失。 + {t('articles.delete.rename-note')}

      {isAlreadyDeleted && (

      - 注意: 该页面已在“deleted”分类中。如需永久删除,请使用“永久删除”。 + {t('articles.delete.warning-label')} {t('articles.delete.warning-already-deleted')}

      )}
      - - {!isAlreadyDeleted && } + + {!isAlreadyDeleted && }
      ) : (
      -

      此操作将永久删除页面且无法恢复。确定要继续吗?

      +

      {t('articles.delete.confirm-permanent')}

      - - + +
      )} diff --git a/web/js/articles/article-diff.tsx b/frontend/articles/article-diff.tsx similarity index 83% rename from web/js/articles/article-diff.tsx rename to frontend/articles/article-diff.tsx index 8279b2b4..7eaf5a74 100644 --- a/web/js/articles/article-diff.tsx +++ b/frontend/articles/article-diff.tsx @@ -1,3 +1,4 @@ +import { t } from '~util/i18n' import * as React from 'react' import { useEffect, useState } from 'react' import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer' @@ -20,20 +21,11 @@ const Styles = styled.div<{ loading?: boolean }>` #source-code.loading { position: relative; min-height: calc(32px + 16px + 16px); - &::after { - content: ' '; + .loader { position: absolute; - background: #0000003f; - z-index: 0; left: 0; right: 0; top: 0; - bottom: 0; - } - .loader { - position: absolute; - left: 16px; - top: 16px; z-index: 1; } } @@ -83,7 +75,7 @@ const ArticleDiffView: React.FC = ({ pageId, pathParams, onClose: onClose setFirstSource(first.source) setSecondSource(second.source) } catch (e) { - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) } finally { setLoading(false) } @@ -105,32 +97,32 @@ const ArticleDiffView: React.FC = ({ pageId, pathParams, onClose: onClose return ( {error && ( - +

      - 错误: {error} + {t('articles.diff.error-label')} {error}

      )} - 关闭 + {t('articles.diff.close')} -

      比较页面版本

      +

      {t('articles.diff.title')}

      如何操作?{t('articles.delete.how-label')} = ({ pageId, onClose, canDelete, canRename checked={!permanent} disabled={loading || saving || !canRename} /> - +
      - - + + - +
      版本 {firstEntry.revNumber}版本 {secondEntry.revNumber}{t('articles.diff.revision-label')} {firstEntry.revNumber}{t('articles.diff.revision-label')} {secondEntry.revNumber}
      创建于:{t('articles.diff.created-label')} {formatDate(new Date(firstEntry.createdAt))} {formatDate(new Date(secondEntry.createdAt))}
      -

      更改源代码:

      +

      {t('articles.diff.source-heading')}

      {loading && } diff --git a/web/js/articles/article-editor.tsx b/frontend/articles/article-editor.tsx similarity index 92% rename from web/js/articles/article-editor.tsx rename to frontend/articles/article-editor.tsx index 6ac33e6d..7d568cd2 100644 --- a/web/js/articles/article-editor.tsx +++ b/frontend/articles/article-editor.tsx @@ -1,3 +1,4 @@ +import { t } from '~util/i18n' import { Editor } from '@monaco-editor/react' import { editor } from 'monaco-editor' import * as React from 'react' @@ -63,20 +64,11 @@ const Styles = styled.div` position: relative; &.loading { - &::after { - content: ' '; + .loader { position: absolute; - background: #0000003f; - z-index: 0; left: 0; right: 0; top: 0; - bottom: 0; - } - .loader { - position: absolute; - left: 16px; - top: 16px; z-index: 1; } } @@ -147,7 +139,7 @@ const ArticleEditor: React.FC = ({ }) .catch(e => { setFatalError(true) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) }) .finally(() => { setLoading(false) @@ -213,7 +205,7 @@ const ArticleEditor: React.FC = ({ .catch(e => { setSaved(false) setFatalError(false) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) }) .finally(() => { setSaving(false) @@ -237,7 +229,7 @@ const ArticleEditor: React.FC = ({ setSavingSuccess(false) setFatalError(false) setSaved(false) - setError(e.error || '连接服务器失败') + setError(e.error || t('common.server-unreachable')) }) .finally(() => { setSaving(false) @@ -319,27 +311,27 @@ const ArticleEditor: React.FC = ({ {saving && ( -

      保存中...

      +

      {t('articles.editor.saving')}

      )} {savingSuccess && ( -

      保存成功!

      +

      {t('articles.editor.saved')}

      )} {error && ( - +

      - 错误: {error} + {t('articles.editor.error-label')} {error}

      )} - {isNew ?

      创建页面

      :

      编辑页面

      } + {isNew ?

      {t('articles.editor.title-new')}

      :

      {t('articles.editor.title-edit')}

      }
      - +
      页面标题:{t('articles.editor.page-title-label')} = ({ )} {useAdvancedEditor && ( = ({ options={monacoOptions} /> )} -

      更改摘要:

      +

      {t('articles.editor.comment-label')}

      + + + + + + +
      +
      {{t "admin.rename"}}
      +
      +
      + + + {{template "row_text" dict "n" "full_name" "k" "admin.f-new-name" "v" .Full "w" "narrow-5" "h" "admin.h-rename"}} +
      + +
      +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/page_list.html b/internal/admin/templates/page_list.html new file mode 100644 index 00000000..68575435 --- /dev/null +++ b/internal/admin/templates/page_list.html @@ -0,0 +1,65 @@ +{{define "page_list.html"}} +{{template "form_error" .Error}} + + + + +

      {{t "admin.count" "n" .Total}}

      + +
      + + + + + + + + + + + + + + {{range .Pages}} + + + + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-page"}}{{t "admin.f-title"}}{{t "admin.f-revisions"}}{{t "admin.f-updated"}}
      {{esc .FullName}}{{if .Title}}{{esc .Title}}{{else}}{{end}}{{.Revisions}}{{.UpdatedAt.Format "2006-01-02 15:04"}}{{t "admin.edit"}}
      {{t "admin.empty"}}
      + +
      + {{t "admin.batch"}} + + + + + + + {{t "admin.h-steps"}} + +
      +
      + +{{template "pager" dict "Base" (printf "%s?q=%s&c=%s&" .Action (urlq .Query) (urlq .Category)) "Page" .Page "Pages" .Pages_}} +{{end}} diff --git a/internal/admin/templates/parts.html b/internal/admin/templates/parts.html new file mode 100644 index 00000000..f975f04a --- /dev/null +++ b/internal/admin/templates/parts.html @@ -0,0 +1,92 @@ +{{define "form_error"}}{{if .}}
      {{esc .}}
      {{end}}{{end}} + +{{define "row_text"}} +
      + +
      + + {{if .h}}{{t .h}}{{end}} +
      +
      +{{end}} + +{{define "row_area"}} +
      + +
      + + {{if .h}}{{t .h}}{{end}} +
      +
      +{{end}} + +{{define "row_number"}} +
      + +
      +
      +{{end}} + +{{define "row_check"}} +
      + {{t .k}} +
      + + {{if .h}}{{t .h}}{{end}} +
      +
      +{{end}} + +{{define "row_static"}} +
      + {{t .k}} +
      {{if .v}}{{esc .v}}{{else}}—{{end}}
      +
      +{{end}} + +{{define "row_pre"}} +
      + {{t .k}} +
      {{esc .v}}
      +
      +{{end}} + +{{define "yes"}}{{if .}}{{t "admin.yes"}}{{end}}{{end}} + +{{define "pager"}} +{{if gt .Pages 1}} + +{{end}} +{{end}} + +{{define "save_row"}} +
      + + {{if .back}}{{t "admin.back-to-list"}}{{end}} + {{if .del}}{{end}} +
      +{{end}} + +{{define "row_image"}} +
      + +
      +
      + {{if .v}}{{end}} + +
      + {{if .v}} + + {{esc .v}} + {{end}} + {{if .h}}{{t .h}}{{end}} +
      +
      +{{end}} diff --git a/internal/admin/templates/report_form.html b/internal/admin/templates/report_form.html new file mode 100644 index 00000000..cbe44334 --- /dev/null +++ b/internal/admin/templates/report_form.html @@ -0,0 +1,34 @@ +{{define "report_form.html"}} +{{template "form_error" .Error}} +
      + + +
      +
      {{t "admin.report-content"}}
      +
      + {{template "row_static" dict "k" "admin.f-reporter" "v" .Report.Reporter}} + {{template "row_static" dict "k" "admin.f-reported" "v" .Report.Reported}} + {{template "row_static" dict "k" "admin.f-created" "v" (.Report.CreatedAt.Format "2006-01-02 15:04")}} + {{template "row_pre" dict "k" "admin.f-reason" "v" .Report.Reason}} + {{if .SeeAll}}{{template "row_pre" dict "k" "admin.f-messages" "v" .Report.Messages}}{{end}} +
      +
      + +
      +
      {{t "admin.review"}}
      +
      +
      + +
      + +
      +
      + {{template "row_area" dict "n" "admin_notes" "k" "admin.f-admin-notes" "v" .Report.AdminNotes}} + {{if .Report.ReviewedBy}}{{template "row_static" dict "k" "admin.f-reviewed-by" "v" .Report.ReviewedBy}}{{end}} + {{template "save_row" dict "back" .Back}} +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/report_list.html b/internal/admin/templates/report_list.html new file mode 100644 index 00000000..acae1556 --- /dev/null +++ b/internal/admin/templates/report_list.html @@ -0,0 +1,28 @@ +{{define "report_list.html"}} + +

      {{t "admin.count" "n" .Total}}

      + + + + + + {{range .Reports}} + + + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-id"}}{{t "admin.f-reporter"}}{{t "admin.f-reported"}}{{t "admin.f-status"}}{{t "admin.f-created"}}
      #{{.ID}}{{esc .Reporter}}{{esc .Reported}}{{enum "admin.report-status-" .Status}}{{.CreatedAt.Format "2006-01-02 15:04"}}
      {{t "admin.empty"}}
      +{{template "pager" dict "Base" (printf "%s?status=%s&" .Action (urlq .Status)) "Page" .Page "Pages" .Pages}} +{{end}} diff --git a/internal/admin/templates/role_category.html b/internal/admin/templates/role_category.html new file mode 100644 index 00000000..627657fc --- /dev/null +++ b/internal/admin/templates/role_category.html @@ -0,0 +1,34 @@ +{{define "role_category.html"}} +{{template "form_error" .Error}} + + + + {{range .Categories}} + + + + + + {{end}} + + + + + + +
      {{t "admin.f-name"}}{{t "admin.f-roles"}}
      +
      + + + + + {{if not .Roles}}{{end}} +
      +
      {{.Roles}}
      +
      + + + +
      +
      +{{end}} diff --git a/internal/admin/templates/role_form.html b/internal/admin/templates/role_form.html new file mode 100644 index 00000000..2694e499 --- /dev/null +++ b/internal/admin/templates/role_form.html @@ -0,0 +1,109 @@ +{{define "role_form.html"}} +{{template "form_error" .Error}} +
      + + +
      +
      {{t "admin.role-basics"}}
      +
      + {{template "row_text" dict "n" "name" "k" "admin.f-name" "v" .Role.Name "w" "narrow-4"}} + {{template "row_text" dict "n" "short_name" "k" "admin.f-short-name" "v" .Role.ShortName "w" "narrow-3"}} +
      + +
      + + {{if .Builtin}}{{t "admin.h-builtin-slug"}}{{end}} +
      +
      +
      + +
      + +
      +
      + {{template "row_number" dict "n" "index" "k" "admin.f-order" "v" .Role.Index}} + {{template "row_check" dict "n" "is_staff" "k" "admin.f-is-staff" "l" "admin.l-is-staff" "v" .Role.IsStaff}} +
      +
      + +
      +
      {{t "admin.role-votes"}}
      +
      + {{template "row_check" dict "n" "group_votes" "k" "admin.f-group-votes" "l" "admin.l-group-votes" "v" .Role.GroupVotes}} + {{template "row_text" dict "n" "votes_title" "k" "admin.f-votes-title" "v" .Role.VotesTitle "w" "narrow-3"}} +
      +
      + +
      +
      {{t "admin.role-badge"}}
      +
      +
      + {{t "admin.f-preview"}} +
      + {{if .Role.BadgeText}}{{esc .Role.BadgeText}}{{else}}{{esc .Role.Slug}}{{end}} + {{t "admin.h-badge-preview"}} +
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      + {{template "row_text" dict "n" "badge_text" "k" "admin.f-badge-text" "v" .Role.BadgeText "w" "narrow-3" "h" "admin.h-badge-text"}} + {{template "row_text" dict "n" "badge_bg" "k" "admin.f-badge-bg" "v" .Role.BadgeBg "w" "narrow-2"}} + {{template "row_text" dict "n" "badge_text_color" "k" "admin.f-badge-text-color" "v" .Role.BadgeTextColor "w" "narrow-2"}} + {{template "row_check" dict "n" "badge_show_border" "k" "admin.f-badge-border" "l" "admin.l-badge-border" "v" .Role.BadgeShowBorder}} + {{template "row_text" dict "n" "color" "k" "admin.f-icon-color" "v" .Role.Color "w" "narrow-2" "h" "admin.h-icon-color"}} + {{template "row_image" dict "n" "icon" "k" "admin.f-icon" "v" .Role.Icon "h" "admin.h-icon" "accept" ".svg,image/svg+xml"}} +
      +
      + + {{if .MayGrant}} +
      +
      {{t "admin.permissions"}}
      +
      +

      {{t "admin.h-permissions"}}

      + + + + + + + + + + + {{range .Grants}} + + {{range .Rows}} + + + + + + + {{end}} + {{end}} + +
      {{t "admin.f-permission"}}{{t "admin.perm-allow"}}{{t "admin.perm-inherit"}}{{t "admin.perm-deny"}}
      {{if .Key}}{{t (printf "admin.perm-group-%s" .Key)}}{{else}}{{t "admin.perm-group-other"}}{{end}}
      {{enum "admin.perm-" .Name}}{{esc .Name}}
      +
      +
      + {{end}} + + {{template "save_row" dict "back" .Back "del" (and .Role.ID (not .Builtin))}} +
      +{{end}} diff --git a/internal/admin/templates/role_list.html b/internal/admin/templates/role_list.html new file mode 100644 index 00000000..490be83f --- /dev/null +++ b/internal/admin/templates/role_list.html @@ -0,0 +1,21 @@ +{{define "role_list.html"}} + + + + + {{range .Roles}} + + + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-role"}}{{t "admin.f-slug"}}{{t "admin.f-order"}}{{t "admin.f-is-staff"}}{{t "admin.f-users"}}
      {{if .Name}}{{esc .Name}}{{else}}{{esc .Slug}}{{end}}{{esc .Slug}}{{.Index}}{{template "yes" .IsStaff}}{{.Users}}
      {{t "admin.empty"}}
      +{{end}} diff --git a/internal/admin/templates/site_form.html b/internal/admin/templates/site_form.html new file mode 100644 index 00000000..36194566 --- /dev/null +++ b/internal/admin/templates/site_form.html @@ -0,0 +1,147 @@ +{{define "site_form.html"}} +{{template "form_error" .Error}} +
      + + +
      +
      {{t "admin.site-identity"}}
      +
      + {{template "row_text" dict "n" "title" "k" "admin.f-site-title" "v" .Site.Title "w" "narrow-5"}} + {{template "row_text" dict "n" "headline" "k" "admin.f-headline" "v" .Site.Headline "w" "narrow-5"}} + {{template "row_text" dict "n" "slug" "k" "admin.f-site-slug" "v" .Site.Slug "w" "narrow-3"}} + {{template "row_text" dict "n" "domain" "k" "admin.f-domain" "v" .Site.Domain "w" "narrow-4"}} + {{template "row_text" dict "n" "media_domain" "k" "admin.f-media-domain" "v" .Site.MediaDomain "w" "narrow-4" "h" "admin.h-media-domain"}} + {{template "row_text" dict "n" "home_page" "k" "admin.f-home-page" "v" .Site.HomePage "w" "narrow-3"}} +
      + +
      + + {{t "admin.h-language"}} +
      +
      +
      + +
      + + + {{t "admin.h-time-zone"}} + +
      +
      +
      +
      + +
      +
      {{t "admin.site-look"}}
      +
      +
      + +
      + +
      +
      +
      + +
      + + {{t "admin.h-system-theme"}} +
      +
      + {{template "row_image" dict "n" "icon" "k" "admin.f-favicon" "v" .Site.Icon "h" "admin.h-favicon"}} + {{template "row_image" dict "n" "auth_icon" "k" "admin.f-auth-icon" "v" .Site.AuthIcon "h" "admin.h-auth-icon"}} + {{template "row_area" dict "n" "footer_license" "k" "admin.f-footer-license" "v" .Site.FooterLicense "h" "admin.h-footer-license"}} +
      +
      + +
      +
      {{t "admin.site-content"}}
      +
      +
      + +
      + +
      +
      +
      + +
      + +
      +
      +
      +
      + +
      +
      {{t "admin.site-accounts"}}
      +
      +
      + +
      + +
      +
      + {{template "row_area" dict "n" "signup_notice" "k" "admin.f-signup-notice" "v" .Site.SignupNotice}} + {{template "row_area" dict "n" "password_help" "k" "admin.f-password-help" "v" .Site.PasswordHelp "h" "admin.h-password-help"}} + {{if .MayGrant}} +
      + +
      + + {{t "admin.h-default-role"}} +
      +
      +
      + +
      + + {{t "admin.h-verified-role"}} +
      +
      + {{end}} +
      +
      + + {{if .MayGrant}} +
      +
      {{t "admin.site-membership"}}
      +
      + {{template "row_check" dict "n" "membership_password_enabled" "k" "admin.f-membership-password" "l" "admin.l-membership-password" "v" .Site.MembershipPasswordEnabled}} + {{template "row_text" dict "n" "membership_password" "k" "admin.f-password" "v" .Site.MembershipPassword "w" "narrow-3"}} +
      + +
      + +
      +
      +
      +
      + {{end}} + +
      + +
      +
      +{{end}} diff --git a/internal/admin/templates/suspicious.html b/internal/admin/templates/suspicious.html new file mode 100644 index 00000000..61986487 --- /dev/null +++ b/internal/admin/templates/suspicious.html @@ -0,0 +1,19 @@ +{{define "suspicious.html"}} +{{if .Cleared}}
      {{t "admin.addresses-cleared" "n" .Cleared}}
      {{end}} +
      +
      +
      {{t "admin.suspicious"}}
      + {{t "admin.count" "n" .Total}} +
      +
      +

      {{t "admin.h-suspicious"}}

      +
      +
      + + + + {{t "admin.h-clear-addresses"}} +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/tag_category_form.html b/internal/admin/templates/tag_category_form.html new file mode 100644 index 00000000..ea78fb2e --- /dev/null +++ b/internal/admin/templates/tag_category_form.html @@ -0,0 +1,16 @@ +{{define "tag_category_form.html"}} +{{template "form_error" .Error}} +
      + +
      +
      {{t "admin.tag-categories"}}
      +
      + {{template "row_text" dict "n" "name" "k" "admin.f-name" "v" .Category.Name "w" "narrow-4"}} + {{template "row_text" dict "n" "slug" "k" "admin.f-slug" "v" .Category.Slug "w" "narrow-3"}} + {{template "row_area" dict "n" "description" "k" "admin.f-description" "v" .Category.Description}} + {{template "row_number" dict "n" "priority" "k" "admin.f-priority" "v" (num .Category.Priority)}} + {{template "save_row" dict "back" .Back "del" .Category.ID}} +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/tag_category_list.html b/internal/admin/templates/tag_category_list.html new file mode 100644 index 00000000..fb8d201a --- /dev/null +++ b/internal/admin/templates/tag_category_list.html @@ -0,0 +1,20 @@ +{{define "tag_category_list.html"}} + + + + + {{range .Categories}} + + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-name"}}{{t "admin.f-slug"}}{{t "admin.f-priority"}}{{t "admin.f-tags"}}
      {{esc .Name}}{{esc .Slug}}{{num .Priority}}{{.Tags}}
      {{t "admin.empty"}}
      +{{end}} diff --git a/internal/admin/templates/tag_form.html b/internal/admin/templates/tag_form.html new file mode 100644 index 00000000..6f25f123 --- /dev/null +++ b/internal/admin/templates/tag_form.html @@ -0,0 +1,23 @@ +{{define "tag_form.html"}} +{{template "form_error" .Error}} +
      + +
      +
      {{t "admin.tags"}}
      +
      + {{template "row_text" dict "n" "name" "k" "admin.f-name" "v" .Tag.Name "w" "narrow-4"}} + {{template "row_check" dict "n" "is_indexed" "k" "admin.f-indexed" "l" "admin.l-indexed-tag" "v" .Tag.IsIndexed}} +
      + +
      + +
      +
      + {{template "save_row" dict "back" .Back "del" .Tag.ID}} +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/tag_list.html b/internal/admin/templates/tag_list.html new file mode 100644 index 00000000..5b36cc50 --- /dev/null +++ b/internal/admin/templates/tag_list.html @@ -0,0 +1,27 @@ +{{define "tag_list.html"}} + + +

      {{t "admin.count" "n" .Total}}

      + + + + {{range .Tags}} + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-tag"}}{{t "admin.f-category"}}{{t "admin.f-articles"}}
      {{esc .Name}}{{if .CategoryName}}{{esc .CategoryName}}{{else}}{{end}}{{.Articles}}
      {{t "admin.empty"}}
      +{{template "pager" dict "Base" (printf "%s?q=%s&" .Action (urlq .Query)) "Page" .Page "Pages" .Pages}} +{{end}} diff --git a/internal/admin/templates/theme_form.html b/internal/admin/templates/theme_form.html new file mode 100644 index 00000000..ac719acf --- /dev/null +++ b/internal/admin/templates/theme_form.html @@ -0,0 +1,24 @@ +{{define "theme_form.html"}} +{{template "form_error" .Error}} +
      + +
      +
      {{t "admin.theme-settings"}}
      +
      + {{template "row_text" dict "n" "name" "k" "admin.f-name" "v" .Theme.Name "w" "narrow-5"}} + {{template "row_text" dict "n" "slug" "k" "admin.f-slug" "v" .Theme.Slug "w" "narrow-3" "h" "admin.h-theme-slug"}} +
      + +
      + +
      +
      + {{template "row_text" dict "n" "external_url" "k" "admin.f-external-url" "v" .Theme.ExternalURL}} + {{template "row_area" dict "n" "css" "k" "admin.f-css" "v" .Theme.CSS "rows" 20}} + {{template "save_row" dict "back" .Back "del" .Theme.ID}} +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/theme_list.html b/internal/admin/templates/theme_list.html new file mode 100644 index 00000000..171e02fc --- /dev/null +++ b/internal/admin/templates/theme_list.html @@ -0,0 +1,20 @@ +{{define "theme_list.html"}} + + + + + {{range .Themes}} + + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-name"}}{{t "admin.f-slug"}}{{t "admin.f-theme-mode"}}{{t "admin.f-updated"}}
      {{esc .Name}}{{esc .Slug}}{{enum "admin.theme-mode-" .Mode}}{{.UpdatedAt.Format "2006-01-02 15:04"}}
      {{t "admin.empty"}}
      +{{end}} diff --git a/internal/admin/templates/ticket_form.html b/internal/admin/templates/ticket_form.html new file mode 100644 index 00000000..07fdeab9 --- /dev/null +++ b/internal/admin/templates/ticket_form.html @@ -0,0 +1,46 @@ +{{define "ticket_form.html"}} +{{template "form_error" .Error}} +
      + + +
      +
      {{t "admin.ticket-content"}}
      +
      + {{template "row_static" dict "k" "admin.f-author" "v" .Ticket.Author}} + {{template "row_static" dict "k" "admin.f-subject" "v" .Ticket.Subject}} + {{template "row_static" dict "k" "admin.f-source-page" "v" .Ticket.SourcePage}} + {{template "row_static" dict "k" "admin.f-created" "v" (.Ticket.CreatedAt.Format "2006-01-02 15:04")}} + {{template "row_pre" dict "k" "admin.f-body" "v" .Ticket.Body}} +
      +
      + +
      +
      {{t "admin.review"}}
      +
      +
      + +
      + +
      +
      + {{if .MayGrant}} +
      + +
      + + {{t "admin.h-granted-role"}} +
      +
      + {{end}} + {{template "row_area" dict "n" "admin_notes" "k" "admin.f-admin-notes" "v" .Ticket.AdminNotes}} + {{if .Ticket.ReviewedBy}}{{template "row_static" dict "k" "admin.f-reviewed-by" "v" .Ticket.ReviewedBy}}{{end}} + {{template "save_row" dict "back" .Back}} +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/ticket_list.html b/internal/admin/templates/ticket_list.html new file mode 100644 index 00000000..e9ba72b4 --- /dev/null +++ b/internal/admin/templates/ticket_list.html @@ -0,0 +1,28 @@ +{{define "ticket_list.html"}} + +

      {{t "admin.count" "n" .Total}}

      + + + + + + {{range .Tickets}} + + + + + + + + {{else}} + + {{end}} + +
      {{t "admin.f-id"}}{{t "admin.f-author"}}{{t "admin.f-subject"}}{{t "admin.f-status"}}{{t "admin.f-created"}}
      #{{.ID}}{{esc .Author}}{{esc .Subject}}{{enum "admin.ticket-status-" .Status}}{{.CreatedAt.Format "2006-01-02 15:04"}}
      {{t "admin.empty"}}
      +{{template "pager" dict "Base" (printf "%s?status=%s&" .Action (urlq .Status)) "Page" .Page "Pages" .Pages}} +{{end}} diff --git a/internal/admin/templates/user_activity.html b/internal/admin/templates/user_activity.html new file mode 100644 index 00000000..9aa5dec9 --- /dev/null +++ b/internal/admin/templates/user_activity.html @@ -0,0 +1,75 @@ +{{define "user_activity.html"}} + + +{{if eq .Show "votes"}} + + + {{range .Votes}} + + {{if $.Dated}} + + {{end}} + + + {{else}} + + {{end}} + +
      {{if .At}}{{.At.Format "2006-01-02"}}
      {{.At.Format "15:04:05"}}{{else}}{{end}}
      + {{esc .Rate}} + +
      {{t "admin.empty"}}
      + +{{else if eq .Show "posts"}} + + + {{range .Posts}} + + + + + {{else}} + + {{end}} + +
      {{.CreatedAt.Format "2006-01-02"}}
      {{.CreatedAt.Format "15:04:05"}}
      + {{if .Article}}{{esc .Article}}{{end}} + + {{if .Name}}
      {{esc .Thread}}
      {{end}} +
      {{t "admin.empty"}}
      + +{{else}} + + + {{range .Edits}} + + + + + {{else}} + + {{end}} + +
      {{.CreatedAt.Format "2006-01-02"}}
      {{.CreatedAt.Format "15:04:05"}}
      + {{range .Flags}}{{esc .Desc}}{{end}} + + {{if .Comment}}
      {{esc .Comment}}
      {{end}} +
      {{t "admin.empty"}}
      +{{end}} + + + + +{{end}} diff --git a/internal/admin/templates/user_bot.html b/internal/admin/templates/user_bot.html new file mode 100644 index 00000000..4950e2cd --- /dev/null +++ b/internal/admin/templates/user_bot.html @@ -0,0 +1,17 @@ +{{define "user_bot.html"}} +{{template "form_error" .Error}} +
      + +
      +
      {{t "admin.new-bot"}}
      +
      +

      {{t "admin.h-bot"}}

      + {{template "row_text" dict "n" "username" "k" "admin.f-username" "v" "" "w" "narrow-4"}} +
      + + {{t "admin.back-to-list"}} +
      +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/user_claim.html b/internal/admin/templates/user_claim.html new file mode 100644 index 00000000..81aff6b6 --- /dev/null +++ b/internal/admin/templates/user_claim.html @@ -0,0 +1,27 @@ +{{define "user_claim.html"}} +{{template "form_error" .Error}} +{{template "made_link" .}} +
      + +
      +
      {{t "admin.new-claim-link"}}
      +
      +

      {{t "admin.h-claim-link"}}

      +
      + +
      + + {{if not .Waiting}}{{t "admin.no-unclaimed"}}{{end}} +
      +
      + {{template "role_picks" .}} +
      + + {{t "admin.invites"}} +
      +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/user_form.html b/internal/admin/templates/user_form.html new file mode 100644 index 00000000..7cf52334 --- /dev/null +++ b/internal/admin/templates/user_form.html @@ -0,0 +1,101 @@ +{{define "user_form.html"}} +{{template "form_error" .Error}} +
      + + + +
      +
      {{t "admin.user-identity"}}
      +
      + {{if .MayAccount}} + {{template "row_text" dict "n" "username" "k" "admin.f-username" "v" .User.Username "w" "narrow-4"}} + {{template "row_text" dict "n" "wikidot_username" "k" "admin.f-wikidot-username" "v" .User.WikidotUsername "w" "narrow-4"}} + {{template "row_text" dict "n" "display_name" "k" "admin.f-display-name" "v" .User.DisplayName "w" "narrow-4"}} + {{if .SeeEmail}}{{template "row_text" dict "n" "email" "k" "admin.f-email" "v" .User.Email "w" "narrow-5"}}{{end}} + {{template "row_area" dict "n" "bio" "k" "admin.f-bio" "v" .User.Bio}} + {{else}} + {{template "row_static" dict "k" "admin.f-username" "v" .User.Username}} + {{template "row_static" dict "k" "admin.f-wikidot-username" "v" .User.WikidotUsername}} + {{template "row_static" dict "k" "admin.f-display-name" "v" .User.DisplayName}} + {{if .SeeEmail}}{{template "row_static" dict "k" "admin.f-email" "v" .User.Email}}{{end}} + {{template "row_static" dict "k" "admin.f-bio" "v" .User.Bio}} + {{end}} + {{template "row_static" dict "k" "admin.f-user-type" "v" .User.Type}} + {{template "row_static" dict "k" "admin.f-api-key" "v" .User.APIKey}} + {{template "row_static" dict "k" "admin.f-rank" "v" (printf "%d" .User.OperationIndex)}} + {{if not .MayAccount}}

      {{t "admin.account-needs-superuser"}}

      {{end}} +
      +
      + + {{if .MaySanction}} +
      +
      {{t "admin.sanctions"}}
      +
      +

      {{t "admin.sanctions-hint"}}

      + {{range .Sanctions}} +
      + {{t .Label}} +
      + + + {{t "admin.sanction-until"}} + +
      +
      + {{end}} +
      +
      + {{end}} + + {{if .MayAccount}} +
      +
      {{t "admin.user-state"}}
      +
      +

      {{t "admin.account-state-hint"}}

      + {{template "row_check" dict "n" "is_active" "k" "admin.f-active" "l" "admin.l-active" "v" .User.IsActive}} +
      + +
      + + {{t "admin.h-until"}} +
      +
      + {{template "row_check" dict "n" "is_forum_active" "k" "admin.f-forum-active" "l" "admin.l-forum-active" "v" .User.IsForumActive}} +
      + +
      + + {{t "admin.h-until"}} +
      +
      + {{template "row_check" dict "n" "can_send_direct_messages" "k" "admin.f-can-message" "l" "admin.l-can-message" "v" .User.CanSendDM}} + {{template "row_check" dict "n" "is_superuser" "k" "admin.f-superuser" "l" "admin.l-superuser" "v" .User.IsSuperuser}} +
      +
      + {{end}} + + {{if .MaySetRoles}} +
      +
      {{t "admin.roles"}}
      +
      +
      + {{t "admin.f-roles"}} +
      + {{range $.Roles}} + + {{end}} +
      +
      +
      +
      + {{end}} + + +
      +{{end}} diff --git a/internal/admin/templates/user_invite.html b/internal/admin/templates/user_invite.html new file mode 100644 index 00000000..a9fe86fa --- /dev/null +++ b/internal/admin/templates/user_invite.html @@ -0,0 +1,19 @@ +{{define "user_invite.html"}} +{{template "form_error" .Error}} +{{template "made_link" .}} +
      + +
      +
      {{t "admin.new-invite-link"}}
      +
      +

      {{t "admin.h-invite-link"}}

      + {{template "row_text" dict "n" "email" "k" "admin.f-email" "v" "" "w" "narrow-5"}} + {{template "role_picks" .}} +
      + + {{t "admin.invites"}} +
      +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/user_list.html b/internal/admin/templates/user_list.html new file mode 100644 index 00000000..6ae7516b --- /dev/null +++ b/internal/admin/templates/user_list.html @@ -0,0 +1,54 @@ +{{define "user_list.html"}} + + + +

      {{t "admin.count" "n" .Total}}

      + + + + + + {{if .SeeEmail}}{{end}} + + + + + + {{range .Users}} + + + + {{if $.SeeEmail}}{{end}} + + + + {{else}} + + {{end}} + +
      {{t "admin.f-user"}}{{t "admin.f-user-type"}}{{t "admin.f-email"}}{{t "admin.f-state"}}
      {{if eq .Type "wikidot"}}{{esc .WikidotUsername}}{{else}}{{esc .Username}}{{end}}{{enum "admin.user-type-" .Type}}{{if .Email}}{{esc .Email}}{{else}}—{{end}} + {{if .IsSuperuser}}{{t "admin.superuser"}} + {{else if .IsActive}}{{t "admin.active"}} + {{else}}{{t "admin.inactive"}}{{end}} + {{t "admin.edit"}}
      {{t "admin.empty"}}
      +{{template "pager" dict "Base" (printf "%s?q=%s&type=%s&" .Action (urlq .Query) (urlq .Kind)) "Page" .Page "Pages" .Pages}} +{{end}} diff --git a/internal/admin/templates/user_mail.html b/internal/admin/templates/user_mail.html new file mode 100644 index 00000000..82a5f6c0 --- /dev/null +++ b/internal/admin/templates/user_mail.html @@ -0,0 +1,20 @@ +{{define "user_mail.html"}} +{{template "form_error" .Error}} +{{if .Done}}
      {{t "admin.invite-sent" "email" .Done}}
      {{end}} +
      + +
      +
      {{if .Target}}{{t "admin.activate"}}{{else}}{{t "admin.mail-invite"}}{{end}}
      +
      +

      {{if .Target}}{{t "admin.h-activate"}}{{else}}{{t "admin.h-mail-invite"}}{{end}}

      + {{if .Target}}{{template "row_static" dict "k" "admin.f-user" "v" .Target}}{{end}} + {{template "row_text" dict "n" "email" "k" "admin.f-email" "v" .Email "w" "narrow-5"}} + {{template "role_picks" .}} +
      + + {{t "admin.back-to-list"}} +
      +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/user_new.html b/internal/admin/templates/user_new.html new file mode 100644 index 00000000..7d9cd1e3 --- /dev/null +++ b/internal/admin/templates/user_new.html @@ -0,0 +1,26 @@ +{{define "user_new.html"}} +{{template "form_error" .Error}} +
      + +
      +
      {{t "admin.new-user"}}
      +
      + {{template "row_text" dict "n" "username" "k" "admin.f-username" "v" "" "w" "narrow-4"}} + {{template "row_text" dict "n" "display_name" "k" "admin.f-display-name" "v" "" "w" "narrow-4"}} +
      + +
      + + {{t "admin.h-new-password"}} +
      +
      + {{template "row_check" dict "n" "is_active" "k" "admin.f-active" "l" "admin.l-active" "v" true}} + {{template "role_picks" .}} +
      + + {{t "admin.back-to-list"}} +
      +
      +
      +
      +{{end}} diff --git a/internal/admin/templates/user_parts.html b/internal/admin/templates/user_parts.html new file mode 100644 index 00000000..1625e8d9 --- /dev/null +++ b/internal/admin/templates/user_parts.html @@ -0,0 +1,22 @@ +{{define "role_picks"}} +{{if .Roles}} +
      + {{t "admin.f-roles"}} +
      + {{range .Roles}} + + {{end}} +
      +
      +{{end}} +{{end}} + +{{define "made_link"}} +{{if .Link}} +
      +

      {{t "admin.link-made"}}

      +

      {{t "admin.h-link-made"}}

      + +
      +{{end}} +{{end}} diff --git a/internal/admin/templates/user_reset_votes.html b/internal/admin/templates/user_reset_votes.html new file mode 100644 index 00000000..79eb3d06 --- /dev/null +++ b/internal/admin/templates/user_reset_votes.html @@ -0,0 +1,11 @@ +{{define "user_reset_votes.html"}} +
      +

      {{t "admin.reset-votes-confirm" "name" .User.Username}}

      +

      {{t "admin.reset-votes-warning"}}

      +
      + + + {{t "admin.cancel"}} +
      +
      +{{end}} diff --git a/internal/admin/theme.go b/internal/admin/theme.go new file mode 100644 index 00000000..dd37078f --- /dev/null +++ b/internal/admin/theme.go @@ -0,0 +1,173 @@ +package admin + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/site" +) + +const themeSlug = "themes" + +var slugPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func init() { + register(screen{slug: themeSlug, label: "admin.themes", need: perms.ManageSite, serve: (*Handler).themes}) +} + +func (h *Handler) themes(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, Prefix+themeSlug), "/") + if r.Method == http.MethodPost { + return h.saveThemeForm(w, r, loc, rest) + } + if rest == "" { + return h.themeList(w, r, loc) + } + return h.themeForm(w, r, loc, rest, "") +} + +func (h *Handler) themeList(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + found, err := h.deps.DB.Themes(r.Context(), siteID(r.Context())) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.themes"), "theme_list.html", map[string]any{ + "Themes": found, + "New": Prefix + themeSlug + "/new", + "Base": Prefix + themeSlug + "/", + }) +} + +func (h *Handler) themeForm(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest, message string) error { + row := db.ThemeRow{Mode: db.ThemeInline} + if rest != "new" { + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row, err = h.deps.DB.Theme(r.Context(), siteID(r.Context()), id) + if errors.Is(err, db.ErrNotFound) { + notFound(w) + return nil + } + if err != nil { + return err + } + } + return h.page(w, r, loc, loc.T("admin.themes"), "theme_form.html", map[string]any{ + "Theme": row, + "CSRF": csrf.Issue(w, r), + "Error": message, + "Action": Prefix + themeSlug + "/" + rest, + "Back": Prefix + themeSlug + "/", + "Modes": []string{db.ThemeInline, db.ThemeExternal}, + }) +} + +func (h *Handler) saveThemeForm(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest string) error { + current := site.FromContext(r.Context()) + if err := csrf.Verify(r, []string{current.Domain, current.MediaDomain}); err != nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return nil + } + if err := r.ParseForm(); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return nil + } + + row := db.ThemeRow{ + Name: strings.TrimSpace(r.PostFormValue("name")), + Slug: strings.TrimSpace(r.PostFormValue("slug")), + Mode: r.PostFormValue("mode"), + CSS: r.PostFormValue("css"), + ExternalURL: strings.TrimSpace(r.PostFormValue("external_url")), + } + if rest != "new" { + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row.ID = id + } + if r.PostFormValue("delete") != "" && row.ID != 0 { + if err := h.deps.DB.DeleteTheme(r.Context(), siteID(r.Context()), row.ID); err != nil { + return err + } + h.noteID(r, db.AdminDeleted, themeSlug, row.ID, row.Name) + redirect(w, Prefix+themeSlug+"/") + return nil + } + + if problem := h.checkTheme(loc, row); problem != "" { + return h.themeForm(w, r, loc, rest, problem) + } + did := db.AdminChanged + if row.ID == 0 { + did = db.AdminCreated + } + id, err := h.deps.DB.SaveTheme(r.Context(), siteID(r.Context()), row) + if err != nil { + return err + } + row.ID = id + if err := h.writeThemeCSS(site.FromContext(r.Context()).Slug, row); err != nil { + return err + } + h.noteID(r, did, themeSlug, row.ID, row.Name) + redirect(w, Prefix+themeSlug+"/") + return nil +} + +func (h *Handler) checkTheme(loc *i18n.Localizer, row db.ThemeRow) string { + switch { + case row.Name == "": + return loc.T("admin.theme-no-name") + case !slugPattern.MatchString(row.Slug): + return loc.T("admin.theme-bad-slug") + case row.Mode != db.ThemeInline && row.Mode != db.ThemeExternal: + return loc.T("admin.theme-bad-mode") + case row.Mode == db.ThemeExternal && row.ExternalURL == "": + return loc.T("admin.theme-no-url") + } + return "" +} + +func (h *Handler) writeThemeCSS(siteSlug string, row db.ThemeRow) error { + if row.Mode != db.ThemeInline || row.Slug == "" { + return nil + } + dir := filepath.Join(h.deps.Files, "theme", siteSlug) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + target := filepath.Join(dir, row.Slug+".css") + tmp := target + ".tmp" + if err := os.WriteFile(tmp, []byte(row.CSS), 0o644); err != nil { + return err + } + return os.Rename(tmp, target) +} + +func notFound(w http.ResponseWriter) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) +} + +func redirect(w http.ResponseWriter, to string) { + seeOther(w, to, http.StatusSeeOther) +} + +func seeOther(w http.ResponseWriter, to string, status int) { + w.Header().Set("Location", to) + w.WriteHeader(status) +} diff --git a/internal/admin/ticket.go b/internal/admin/ticket.go new file mode 100644 index 00000000..8c8fc83f --- /dev/null +++ b/internal/admin/ticket.go @@ -0,0 +1,187 @@ +package admin + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/perms" +) + +const ( + ticketSlug = "tickets" + membershipSlug = "membership-applications" + inviteSlug = "invite-links" +) + +var ticketStatuses = []string{db.TicketPending, db.TicketApproved, db.TicketRejected, db.TicketClosed} + +func init() { + register(screen{slug: ticketSlug, label: "admin.tickets", need: perms.ViewUserTickets, serve: (*Handler).tickets}) + register(screen{slug: membershipSlug, label: "admin.membership", need: perms.ReviewMembershipApplications, serve: (*Handler).membership}) + register(screen{slug: inviteSlug, label: "admin.invites", need: perms.ManageUsers, serve: (*Handler).invites}) +} + +func (h *Handler) tickets(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + return h.ticketScreen(w, r, loc, ticketSlug, db.TicketKind, "admin.tickets", false) +} + +func (h *Handler) membership(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + return h.ticketScreen(w, r, loc, membershipSlug, db.MembershipApplyKind, "admin.membership", true) +} + +func (h *Handler) ticketScreen(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, slug, kind, title string, grants bool) error { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, Prefix+slug), "/") + ctx := r.Context() + + if r.Method == http.MethodPost { + if !h.verified(w, r) { + return nil + } + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + stored, err := h.deps.DB.AdminTicket(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) || (err == nil && stored.Kind != kind) { + notFound(w) + return nil + } + if err != nil { + return err + } + status := r.PostFormValue("status") + if !contains(ticketStatuses, status) { + return h.ticketForm(w, r, loc, slug, kind, title, grants, rest, loc.T("admin.report-bad-status")) + } + var role *int64 + if grants { + granted, _, err := h.access(ctx) + if err != nil { + return err + } + if granted.Has(perms.ManagePermissions) { + role = optionalID(r.PostFormValue("granted_role")) + } else { + role = stored.GrantedID + } + } + mine := auth.FromContext(ctx) + if mine == nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return nil + } + err = h.deps.DB.ReviewTicket(ctx, siteID(ctx), id, status, r.PostFormValue("admin_notes"), mine.ID, role, time.Now()) + if err != nil { + return err + } + h.noteID(r, db.AdminChanged, slug, id, status) + redirect(w, Prefix+slug+"/") + return nil + } + + if rest == "" { + status := r.URL.Query().Get("status") + if !contains(ticketStatuses, status) { + status = "" + } + page := atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + found, total, err := h.deps.DB.AdminTickets(ctx, siteID(ctx), kind, status, perPage, (page-1)*perPage) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T(title), "ticket_list.html", map[string]any{ + "Tickets": found, + "Status": status, + "Statuses": ticketStatuses, + "Page": page, + "Pages": (total + perPage - 1) / perPage, + "Total": total, + "Base": Prefix + slug + "/", + "Action": Prefix + slug + "/", + }) + } + return h.ticketForm(w, r, loc, slug, kind, title, grants, rest, "") +} + +func (h *Handler) ticketForm(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, slug, kind, title string, grants bool, rest, problem string) error { + ctx := r.Context() + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row, err := h.deps.DB.AdminTicket(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) || (err == nil && row.Kind != kind) { + notFound(w) + return nil + } + if err != nil { + return err + } + var roleList []db.RoleChoice + granted, _, err := h.access(ctx) + if err != nil { + return err + } + mayGrant := grants && granted.Has(perms.ManagePermissions) + if mayGrant { + roleList, err = h.deps.DB.AllRoles(ctx, siteID(ctx)) + if err != nil { + return err + } + } + return h.page(w, r, loc, loc.T(title), "ticket_form.html", map[string]any{ + "Ticket": row, + "Statuses": ticketStatuses, + "Roles": roleList, + "MayGrant": mayGrant, + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Action": Prefix + slug + "/" + rest, + "Back": Prefix + slug + "/", + }) +} + +func (h *Handler) invites(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + ctx := r.Context() + if r.Method == http.MethodPost { + if !h.verified(w, r) { + return nil + } + if id := optionalID(r.PostFormValue("id")); id != nil && r.PostFormValue("delete") != "" { + if err := h.deps.DB.DeleteInvite(ctx, siteID(ctx), *id); err != nil { + return err + } + h.noteID(r, db.AdminDeleted, inviteSlug, *id, "") + } + redirect(w, Prefix+inviteSlug+"/") + return nil + } + page := atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + found, total, err := h.deps.DB.AdminInvites(ctx, siteID(ctx), perPage, (page-1)*perPage) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.invites"), "invite_list.html", map[string]any{ + "Invites": found, + "Page": page, + "Pages": (total + perPage - 1) / perPage, + "Total": total, + "CSRF": csrf.Issue(w, r), + "Action": Prefix + inviteSlug + "/", + }) +} diff --git a/internal/admin/update.go b/internal/admin/update.go new file mode 100644 index 00000000..ea212d4e --- /dev/null +++ b/internal/admin/update.go @@ -0,0 +1,89 @@ +package admin + +import ( + "net/http" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/escape" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/update" +) + +const updateSlug = "update" + +type updateNotice struct { + Error bool + Text string + Detail string + Notes string + Actions bool + CSRF string +} + +func (h *Handler) updateNotices(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) []updateNotice { + if h.deps.Updates == nil { + return nil + } + user := auth.FromContext(r.Context()) + super := user != nil && user.IsSuperuser + var out []updateNotice + for _, n := range h.deps.Updates.Notices(r.Context()) { + v := updateNotice{Notes: n.Notes} + switch n.Kind { + case update.NoticeScheduled: + v.Text = loc.T("update.notice-scheduled", "version", escape.HTML(n.Version), "time", localTime(n.At)) + v.Actions = super + case update.NoticeAvailable: + v.Text = loc.T("update.notice-available", "version", escape.HTML(n.Version)) + v.Detail = loc.T("update.reason-"+n.Reason.Code, "detail", n.Reason.Detail) + case update.NoticeUpdated: + v.Text = loc.T("update.notice-updated", "version", escape.HTML(n.Version), "from", escape.HTML(n.From)) + case update.NoticeRolledBack: + v.Error = true + v.Text = loc.T("update.notice-rolled-back", "version", escape.HTML(n.Version), "from", escape.HTML(n.From), "time", localTime(n.At)) + if super { + v.Detail = n.Error + } + } + if v.Actions && w != nil { + v.CSRF = csrf.Issue(w, r) + } + out = append(out, v) + } + return out +} + +func localTime(at time.Time) string { + utc := at.UTC() + return `` +} + +func (h *Handler) updateAction(w http.ResponseWriter, r *http.Request, action string) error { + user := auth.FromContext(r.Context()) + if h.deps.Updates == nil || user == nil || !user.IsSuperuser || r.Method != http.MethodPost { + h.next.ServeHTTP(w, r) + return nil + } + if !h.verified(w, r) { + return nil + } + var err error + switch action { + case "postpone": + err = h.deps.Updates.Postpone(r.Context()) + case "skip": + err = h.deps.Updates.Skip(r.Context()) + default: + h.next.ServeHTTP(w, r) + return nil + } + if err != nil { + return err + } + h.note(r, db.AdminChanged, updateSlug, action, "") + seeOther(w, Prefix, http.StatusSeeOther) + return nil +} diff --git a/internal/admin/update_test.go b/internal/admin/update_test.go new file mode 100644 index 00000000..b424d21d --- /dev/null +++ b/internal/admin/update_test.go @@ -0,0 +1,49 @@ +package admin + +import ( + "strings" + "testing" + "time" +) + +func TestLayoutShowsUpdateNotices(t *testing.T) { + h, err := New(Deps{}, nil) + if err != nil { + t.Fatalf("New() err = %v, want nil", err) + } + loc := testLocalizer(t) + tpl, err := h.bind(loc) + if err != nil { + t.Fatalf("bind() err = %v, want nil", err) + } + view := layoutView{ + Title: "Dashboard", + AdminURL: Prefix, + loc: loc, + Updates: []updateNotice{ + {Text: loc.T("update.notice-scheduled", "version", "v1.1.0", "time", localTime(testTime)), Actions: true, CSRF: "token", Notes: "https://example.org/notes"}, + {Error: true, Text: loc.T("update.notice-rolled-back", "version", "v1.1.0", "from", "v1.0.0", "time", localTime(testTime)), Detail: "health failed"}, + }, + } + var out strings.Builder + if err := tpl.ExecuteTemplate(&out, "layout.html", view); err != nil { + t.Fatalf("ExecuteTemplate(layout.html) err = %v, want nil", err) + } + got := out.String() + for _, want := range []string{ + `action="/-/admin/update/postpone"`, + `action="/-/admin/update/skip"`, + `value="token"`, + `href="https://example.org/notes"`, + `class="alert alert-error"`, + `health <check> failed`, + `data-local-time`, + "v1.1.0", + } { + if !strings.Contains(got, want) { + t.Errorf("layout.html = %q, want it to contain %q", got, want) + } + } +} + +var testTime = time.Date(2026, 10, 2, 3, 42, 0, 0, time.UTC) diff --git a/internal/admin/upload.go b/internal/admin/upload.go new file mode 100644 index 00000000..980fe1ea --- /dev/null +++ b/internal/admin/upload.go @@ -0,0 +1,174 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/paths" +) + +const uploadRoot = "-" + +const maxIconBytes = 2 << 20 + +var ( + errUploadType = errors.New("admin: the file is not of an accepted type") + errUploadSize = errors.New("admin: the file is too large") +) + +type uploadRule struct { + dir string + exts []string + check func(head []byte) bool +} + +var siteIcons = uploadRule{ + dir: "sites", + exts: []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}, + check: func(head []byte) bool { + return strings.HasPrefix(http.DetectContentType(head), "image/") + }, +} + +var roleIcon = uploadRule{ + dir: "roles", + exts: []string{".svg"}, + check: func(head []byte) bool { return strings.Contains(string(head), " maxIconBytes { + return "", errUploadSize + } + ext := strings.ToLower(filepath.Ext(header.Filename)) + if !contains(rule.exts, ext) { + return "", errUploadType + } + + src, err := header.Open() + if err != nil { + return "", err + } + defer src.Close() + + head := make([]byte, 1024) + n, err := io.ReadFull(src, head) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return "", err + } + if !rule.check(head[:n]) { + return "", errUploadType + } + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", err + } + + name, full, err := h.freeName(rule.dir, cleanName(header.Filename, ext)) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return "", err + } + dst, err := os.OpenFile(full, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return "", err + } + defer dst.Close() + if _, err := io.Copy(dst, io.LimitReader(src, maxIconBytes)); err != nil { + return "", err + } + return path.Join(uploadRoot, rule.dir, name), nil +} + +func (h *Handler) freeName(dir, name string) (string, string, error) { + base := strings.TrimSuffix(name, filepath.Ext(name)) + ext := filepath.Ext(name) + for i := 0; i < 8; i++ { + full, err := paths.Resolve(h.deps.Files, filepath.Join(uploadRoot, dir, name)) + if err != nil { + return "", "", err + } + if _, err := os.Stat(full); errors.Is(err, os.ErrNotExist) { + return name, full, nil + } + var suffix [3]byte + if _, err := rand.Read(suffix[:]); err != nil { + return "", "", err + } + name = base + "-" + hex.EncodeToString(suffix[:]) + ext + } + return "", "", errors.New("admin: no free name for the upload") +} + +func cleanName(name, ext string) string { + base := strings.TrimSuffix(filepath.Base(name), filepath.Ext(name)) + var b strings.Builder + for _, r := range base { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r + 32) + default: + b.WriteByte('-') + } + } + trimmed := strings.Trim(b.String(), "-") + if trimmed == "" { + trimmed = "icon" + } + if len(trimmed) > 60 { + trimmed = trimmed[:60] + } + return trimmed + ext +} + +func (h *Handler) pickIcon(r *http.Request, field, stored string, rule uploadRule) (string, error) { + if r.PostFormValue("clear_"+field) != "" { + return "", nil + } + name, err := h.store(r, field, rule) + if err != nil { + return "", err + } + if name == "" { + return stored, nil + } + return name, nil +} + +func iconMessage(loc *i18n.Localizer, err error) string { + switch { + case errors.Is(err, errUploadType): + return loc.T("admin.icon-bad-type") + case errors.Is(err, errUploadSize): + return loc.T("admin.icon-too-large") + } + return "" +} + +func (h *Handler) iconProblem(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, err error) error { + if message := iconMessage(loc, err); message != "" { + return h.siteForm(w, r, loc, message, "") + } + return err +} diff --git a/internal/admin/user.go b/internal/admin/user.go new file mode 100644 index 00000000..38c05644 --- /dev/null +++ b/internal/admin/user.go @@ -0,0 +1,310 @@ +package admin + +import ( + "context" + "errors" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/timezone" +) + +const ( + userSlug = "users" + perPage = 50 +) + +var userTypes = []string{"normal", "wikidot", "bot", "system"} + +func init() { + register(screen{slug: userSlug, label: "admin.users", need: perms.ManageUsers, serve: (*Handler).users}) +} + +func (h *Handler) users(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, Prefix+userSlug), "/") + if id, tail, ok := strings.Cut(rest, "/"); ok { + switch tail { + case actionResetVote: + if !h.allowed(w, r, perms.ResetMemberVotes) { + return nil + } + return h.resetVotes(w, r, loc, id) + case actionActivate: + return h.activate(w, r, loc, id) + case actionActivity: + return h.userActivity(w, r, loc, id) + } + notFound(w) + return nil + } + if contains(userActions, rest) { + if !h.allowed(w, r, neededForAction(rest)) { + return nil + } + if r.Method != http.MethodPost { + return h.userAction(w, r, loc, rest) + } + if !h.verified(w, r) { + return nil + } + switch rest { + case actionNew: + return h.saveNewUser(w, r, loc) + case actionInvite: + return h.saveInviteLink(w, r, loc) + case actionClaim: + return h.saveClaimLink(w, r, loc) + case actionBot: + return h.saveBot(w, r, loc) + case actionMail: + return h.sendInvite(w, r, loc, nil) + } + } + if r.Method == http.MethodPost { + return h.saveUser(w, r, loc, rest) + } + if rest == "" { + return h.userList(w, r, loc) + } + return h.userForm(w, r, loc, rest, "") +} + +func (h *Handler) userList(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + ctx := r.Context() + query := strings.TrimSpace(r.URL.Query().Get("q")) + kind := r.URL.Query().Get("type") + if !contains(userTypes, kind) { + kind = "" + } + page := atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + found, total, err := h.deps.DB.AdminUsers(ctx, query, kind, perPage, (page-1)*perPage) + if err != nil { + return err + } + granted, _, err := h.access(ctx) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.users"), "user_list.html", map[string]any{ + "Users": found, + "Query": query, + "Kind": kind, + "Types": userTypes, + "Page": page, + "Pages": (total + perPage - 1) / perPage, + "Total": total, + "SeeEmail": granted.Has(perms.ViewSensitiveInfo), + "Base": Prefix + userSlug + "/", + "Action": Prefix + userSlug + "/", + }) +} + +func (h *Handler) userForm(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest, problem string) error { + ctx := r.Context() + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row, err := h.deps.DB.AdminUser(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) { + notFound(w) + return nil + } + if err != nil { + return err + } + allowed, err := h.mayEdit(ctx, row) + if err != nil { + return err + } + if !allowed { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return nil + } + + roleList, err := h.deps.DB.AllRoles(ctx, siteID(ctx)) + if err != nil { + return err + } + choices := make([]db.RoleChoice, 0, len(roleList)) + for _, one := range roleList { + if !slices.Contains(builtinRoles, one.Slug) { + choices = append(choices, one) + } + } + granted, _, err := h.access(ctx) + if err != nil { + return err + } + mine := auth.FromContext(ctx) + + sanctions, err := h.deps.DB.MemberSanctions(ctx, siteID(ctx), row.ID) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.users"), "user_form.html", map[string]any{ + "User": row, + "Zone": site.Zone(ctx), + "ZoneName": site.Zone(ctx).String(), + "Roles": choices, + "Held": row.Roles, + "MaySetRoles": h.maySetRoles(mine, granted, row), + "Sanctions": sanctionRows(sanctions, granted), + "MaySanction": len(sanctionRows(sanctions, granted)) > 0, + "MayAccount": mine != nil && mine.IsSuperuser, + "ResetVotes": Prefix + userSlug + "/" + rest + "/" + actionResetVote, + "MayReset": granted.Has(perms.ResetMemberVotes), + "Activity": Prefix + userSlug + "/" + rest + "/" + actionActivity, + "Activate": Prefix + userSlug + "/" + rest + "/" + actionActivate, + "MaySuper": mine != nil && mine.IsSuperuser, + "SeeEmail": granted.Has(perms.ViewSensitiveInfo), + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Action": Prefix + userSlug + "/" + rest, + "Back": Prefix + userSlug + "/", + }) +} + +func (h *Handler) saveUser(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest string) error { + ctx := r.Context() + current := site.FromContext(ctx) + if err := csrf.Verify(r, []string{current.Domain, current.MediaDomain}); err != nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return nil + } + if err := r.ParseForm(); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return nil + } + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + stored, err := h.deps.DB.AdminUser(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) { + notFound(w) + return nil + } + if err != nil { + return err + } + allowed, err := h.mayEdit(ctx, stored) + if err != nil { + return err + } + if !allowed { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return nil + } + granted, _, err := h.access(ctx) + if err != nil { + return err + } + mine := auth.FromContext(ctx) + + next := stored + // The account itself is shared by every site of the instance, so only a + // superuser changes it. + maySuper := mine != nil && mine.IsSuperuser + if maySuper { + zone := editorZone(r) + next.Username = strings.TrimSpace(r.PostFormValue("username")) + next.WikidotUsername = strings.TrimSpace(r.PostFormValue("wikidot_username")) + next.DisplayName = strings.TrimSpace(r.PostFormValue("display_name")) + next.Bio = r.PostFormValue("bio") + next.IsActive = r.PostFormValue("is_active") != "" + next.IsForumActive = r.PostFormValue("is_forum_active") != "" + next.CanSendDM = r.PostFormValue("can_send_direct_messages") != "" + next.InactiveUntil = optionalTime(r.PostFormValue("inactive_until"), zone) + next.ForumInactiveUntil = optionalTime(r.PostFormValue("forum_inactive_until"), zone) + next.IsSuperuser = r.PostFormValue("is_superuser") != "" + if granted.Has(perms.ViewSensitiveInfo) { + next.Email = strings.TrimSpace(r.PostFormValue("email")) + } + } + maySetRoles := h.maySetRoles(mine, granted, stored) + if maySetRoles { + next.Roles = nil + for _, raw := range r.PostForm["roles"] { + if got := optionalID(raw); got != nil { + next.Roles = append(next.Roles, *got) + } + } + } + + if next.Username == "" { + return h.userForm(w, r, loc, rest, loc.T("admin.user-no-name")) + } + err = h.deps.DB.SaveAdminUser(ctx, siteID(ctx), next, builtinRoles, maySetRoles, maySuper) + if err != nil { + return err + } + if err := h.saveSanctions(r, next.ID); err != nil { + return err + } + h.noteID(r, db.AdminChanged, userSlug, next.ID, next.Username) + redirect(w, Prefix+userSlug+"/") + return nil +} + +func (h *Handler) mayEdit(ctx context.Context, target db.AdminUserRow) (bool, error) { + mine := auth.FromContext(ctx) + if mine == nil { + return false, nil + } + if mine.IsSuperuser { + return true, nil + } + rank, err := h.deps.DB.OperationIndex(ctx, siteID(ctx), mine.ID) + if err != nil { + return false, err + } + return target.OperationIndex > rank, nil +} + +func (h *Handler) maySetRoles(mine *db.User, granted perms.Set, target db.AdminUserRow) bool { + if mine == nil { + return false + } + if mine.IsSuperuser { + return true + } + if target.IsSuperuser { + return false + } + return granted.Has(perms.ManagePermissions) +} + +func editorZone(r *http.Request) *time.Location { + if name := r.PostFormValue("editor_zone"); timezone.Valid(name) { + return timezone.Load(name) + } + return site.Zone(r.Context()) +} + +func optionalTime(raw string, zone *time.Location) *time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} { + if at, err := time.ParseInLocation(layout, raw, zone); err == nil { + return &at + } + } + return nil +} diff --git a/internal/admin/useractions.go b/internal/admin/useractions.go new file mode 100644 index 00000000..d495687a --- /dev/null +++ b/internal/admin/useractions.go @@ -0,0 +1,417 @@ +package admin + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/password" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/token" +) + +const ( + actionNew = "new" + actionMail = "invite" + actionInvite = "invite-link" + actionClaim = "claim-link" + actionBot = "bot" + actionResetVote = "reset-votes" + actionActivate = "activate" +) + +const ( + inviteKindRegister = "register" + inviteKindClaim = "claim" + inviteByLink = "link" + inviteByMail = "email" + acceptPrefix = "/-/accept/" +) + +var userActions = []string{actionNew, actionMail, actionInvite, actionClaim, actionBot} + +func (h *Handler) userAction(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, what string) error { + switch what { + case actionNew: + return h.newUser(w, r, loc, "") + case actionInvite: + return h.inviteLink(w, r, loc, "", "") + case actionClaim: + return h.claimLink(w, r, loc, "", "") + case actionBot: + return h.newBot(w, r, loc, "") + case actionMail: + return h.mailInvite(w, r, loc, nil, "", "") + } + notFound(w) + return nil +} + +func (h *Handler) newUser(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, problem string) error { + roleList, err := h.deps.DB.AllRoles(r.Context(), siteID(r.Context())) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.new-user"), "user_new.html", map[string]any{ + "Roles": h.grantableRoles(r, roleList), + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Action": Prefix + userSlug + "/" + actionNew, + "Back": Prefix + userSlug + "/", + }) +} + +func (h *Handler) saveNewUser(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + ctx := r.Context() + name := strings.TrimSpace(r.PostFormValue("username")) + secret := r.PostFormValue("password") + switch { + case name == "": + return h.newUser(w, r, loc, loc.T("admin.user-no-name")) + case len(secret) < 8: + return h.newUser(w, r, loc, loc.T("admin.password-too-short")) + } + taken, err := h.deps.DB.UsernameTaken(ctx, name) + if err != nil { + return err + } + if taken { + return h.newUser(w, r, loc, loc.T("admin.name-taken")) + } + hash, err := password.Hash(secret) + if err != nil { + return err + } + id, err := h.deps.DB.CreateUser(ctx, name, strings.TrimSpace(r.PostFormValue("display_name")), + hash, r.PostFormValue("is_active") != "", time.Now()) + if err != nil { + return err + } + if err := h.grantPicked(r, id); err != nil { + return err + } + h.noteID(r, db.AdminCreated, userSlug, id, name) + redirect(w, Prefix+userSlug+"/"+strconv.FormatInt(id, 10)) + return nil +} + +func (h *Handler) newBot(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, problem string) error { + return h.page(w, r, loc, loc.T("admin.new-bot"), "user_bot.html", map[string]any{ + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Action": Prefix + userSlug + "/" + actionBot, + "Back": Prefix + userSlug + "/", + }) +} + +func (h *Handler) saveBot(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + ctx := r.Context() + name := strings.TrimSpace(r.PostFormValue("username")) + if name == "" { + return h.newBot(w, r, loc, loc.T("admin.user-no-name")) + } + taken, err := h.deps.DB.UsernameTaken(ctx, name) + if err != nil { + return err + } + if taken { + return h.newBot(w, r, loc, loc.T("admin.name-taken")) + } + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return err + } + id, err := h.deps.DB.CreateBot(ctx, name, base64.RawURLEncoding.EncodeToString(raw[:]), time.Now()) + if err != nil { + return err + } + h.noteID(r, db.AdminCreated, userSlug, id, name) + redirect(w, Prefix+userSlug+"/"+strconv.FormatInt(id, 10)) + return nil +} + +func (h *Handler) inviteLink(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, problem, made string) error { + roleList, err := h.deps.DB.AllRoles(r.Context(), siteID(r.Context())) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.new-invite-link"), "user_invite.html", map[string]any{ + "Roles": h.grantableRoles(r, roleList), + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Link": made, + "Action": Prefix + userSlug + "/" + actionInvite, + "Back": Prefix + inviteSlug + "/", + }) +} + +func (h *Handler) saveInviteLink(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + ctx := r.Context() + email := strings.TrimSpace(r.PostFormValue("email")) + if email == "" { + return h.inviteLink(w, r, loc, loc.T("admin.invite-no-email"), "") + } + if _, err := h.deps.DB.UserByEmail(ctx, email); err == nil { + return h.inviteLink(w, r, loc, loc.T("admin.invite-email-taken"), "") + } else if !errors.Is(err, db.ErrNotFound) { + return err + } + now := time.Now() + id, err := h.deps.DB.CreateInvitedUser(ctx, email, now) + if err != nil { + return err + } + if err := h.grantPicked(r, id); err != nil { + return err + } + link, err := h.mintLink(w, r, inviteKindRegister, id, email, "", now) + if err != nil { + return err + } + h.noteID(r, db.AdminCreated, inviteSlug, id, email) + return h.inviteLink(w, r, loc, "", link) +} + +func (h *Handler) claimLink(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, problem, made string) error { + ctx := r.Context() + waiting, err := h.deps.DB.UnclaimedWikidotUsers(ctx) + if err != nil { + return err + } + roleList, err := h.deps.DB.AllRoles(ctx, siteID(ctx)) + if err != nil { + return err + } + return h.page(w, r, loc, loc.T("admin.new-claim-link"), "user_claim.html", map[string]any{ + "Waiting": waiting, + "Roles": h.grantableRoles(r, roleList), + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Link": made, + "Action": Prefix + userSlug + "/" + actionClaim, + "Back": Prefix + inviteSlug + "/", + }) +} + +func (h *Handler) saveClaimLink(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer) error { + ctx := r.Context() + id, err := strconv.ParseInt(r.PostFormValue("user"), 10, 64) + if err != nil { + return h.claimLink(w, r, loc, loc.T("admin.claim-no-user"), "") + } + row, err := h.deps.DB.AdminUser(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) || row.Type != db.UserTypeWikidot || row.IsActive { + return h.claimLink(w, r, loc, loc.T("admin.claim-no-user"), "") + } + if err != nil { + return err + } + if err := h.grantPicked(r, id); err != nil { + return err + } + link, err := h.mintLink(w, r, inviteKindClaim, id, "", row.WikidotUsername, time.Now()) + if err != nil { + return err + } + h.noteID(r, db.AdminCreated, inviteSlug, id, row.WikidotUsername) + return h.claimLink(w, r, loc, "", link) +} + +func (h *Handler) mintLink(w http.ResponseWriter, r *http.Request, kind string, id int64, email, wikidotName string, now time.Time) (string, error) { + return h.mintLinkAs(w, r, kind, inviteByLink, id, email, wikidotName, now) +} + +func (h *Handler) mintLinkAs(w http.ResponseWriter, r *http.Request, kind, delivery string, id int64, email, wikidotName string, now time.Time) (string, error) { + ctx := r.Context() + current := site.FromContext(ctx) + minted := h.deps.Tokens.Make(token.InviteValue(id, false), now) + uid := base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10))) + + var owner *int64 + if by := auth.FromContext(ctx); by != nil { + owner = &by.ID + } + if _, err := h.deps.DB.CreateInviteLink(ctx, siteID(ctx), kind, delivery, email, wikidotName, + minted, uid, owner, id, now); err != nil { + return "", err + } + return h.deps.Trust.Scheme(r) + "://" + current.Domain + acceptPrefix + uid + "/" + minted, nil +} + +func (h *Handler) resetVotes(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest string) error { + ctx := r.Context() + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row, err := h.deps.DB.AdminUser(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) { + notFound(w) + return nil + } + if err != nil { + return err + } + if r.Method != http.MethodPost { + return h.page(w, r, loc, loc.T("admin.reset-votes"), "user_reset_votes.html", map[string]any{ + "User": row, + "CSRF": csrf.Issue(w, r), + "Action": Prefix + userSlug + "/" + rest + "/" + actionResetVote, + "Back": Prefix + userSlug + "/" + rest, + }) + } + if !h.verified(w, r) { + return nil + } + if _, err := h.deps.DB.ResetUserVotes(ctx, siteID(ctx), id); err != nil { + return err + } + h.noteID(r, db.AdminChanged, userSlug, id, row.Username) + redirect(w, Prefix+userSlug+"/"+rest) + return nil +} + +func (h *Handler) grantableRoles(r *http.Request, all []db.RoleChoice) []db.RoleChoice { + if !grantsFrom(r.Context()).Has(perms.ManagePermissions) { + return nil + } + out := make([]db.RoleChoice, 0, len(all)) + for _, one := range all { + if !contains(builtinRoles, one.Slug) { + out = append(out, one) + } + } + return out +} + +func (h *Handler) grantPicked(r *http.Request, userID int64) error { + if !grantsFrom(r.Context()).Has(perms.ManagePermissions) { + return nil + } + for _, raw := range r.PostForm["roles"] { + role, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + continue + } + if err := h.deps.DB.GrantRole(r.Context(), siteID(r.Context()), userID, role); err != nil { + return err + } + } + return nil +} + +func (h *Handler) mailInvite(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, + target *db.AdminUserRow, problem, done string) error { + + roleList, err := h.deps.DB.AllRoles(r.Context(), siteID(r.Context())) + if err != nil { + return err + } + action := Prefix + userSlug + "/" + actionMail + name := "" + if target != nil { + action = Prefix + userSlug + "/" + strconv.FormatInt(target.ID, 10) + "/" + actionActivate + name = target.WikidotUsername + if name == "" { + name = target.Username + } + } + return h.page(w, r, loc, loc.T("admin.mail-invite"), "user_mail.html", map[string]any{ + "Roles": h.grantableRoles(r, roleList), + "Target": name, + "Email": strings.TrimSpace(r.PostFormValue("email")), + "CSRF": csrf.Issue(w, r), + "Error": problem, + "Done": done, + "Action": action, + "Back": Prefix + userSlug + "/", + }) +} + +func (h *Handler) sendInvite(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, target *db.AdminUserRow) error { + ctx := r.Context() + if h.deps.Mail == nil { + return h.mailInvite(w, r, loc, target, loc.T("admin.no-mailer"), "") + } + email := strings.TrimSpace(r.PostFormValue("email")) + if email == "" { + return h.mailInvite(w, r, loc, target, loc.T("admin.invite-no-email"), "") + } + if held, err := h.deps.DB.UserByEmail(ctx, email); err == nil { + if target == nil || held.ID != target.ID { + return h.mailInvite(w, r, loc, target, loc.T("admin.invite-email-taken"), "") + } + } else if !errors.Is(err, db.ErrNotFound) { + return err + } + + now := time.Now() + kind, wikidotName := inviteKindRegister, "" + id := int64(0) + if target == nil { + fresh, err := h.deps.DB.CreateInvitedUser(ctx, email, now) + if err != nil { + return err + } + id = fresh + } else { + id = target.ID + if err := h.deps.DB.SetEmail(ctx, id, email); err != nil { + return err + } + if target.Type == db.UserTypeWikidot { + kind, wikidotName = inviteKindClaim, target.WikidotUsername + } + } + if err := h.grantPicked(r, id); err != nil { + return err + } + + link, err := h.mintLinkAs(w, r, kind, inviteByMail, id, email, wikidotName, now) + if err != nil { + return err + } + current := site.FromContext(ctx) + err = h.deps.Mail.Send(ctx, []string{email}, + loc.T("email.invite-subject", "site", current.Title), + loc.T("email.invite-body", "link", link, "site", current.Title)) + if err != nil { + h.deps.logger().Error("send invitation", "err", err) + return h.mailInvite(w, r, loc, target, loc.T("admin.invite-not-sent"), "") + } + h.noteID(r, db.AdminCreated, inviteSlug, id, email) + return h.mailInvite(w, r, loc, target, "", email) +} + +func (h *Handler) activate(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest string) error { + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row, err := h.deps.DB.AdminUser(r.Context(), siteID(r.Context()), id) + if errors.Is(err, db.ErrNotFound) { + notFound(w) + return nil + } + if err != nil { + return err + } + if r.Method != http.MethodPost { + return h.mailInvite(w, r, loc, &row, "", "") + } + if !h.verified(w, r) { + return nil + } + return h.sendInvite(w, r, loc, &row) +} diff --git a/internal/admin/useractivity.go b/internal/admin/useractivity.go new file mode 100644 index 00000000..4a2b73da --- /dev/null +++ b/internal/admin/useractivity.go @@ -0,0 +1,215 @@ +package admin + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/changelog" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/repo" +) + +const actionActivity = "activity" + +const activityPerPage = 40 + +const ( + showEdits = "edits" + showVotes = "votes" + showPosts = "posts" +) + +var activityTabs = []string{showEdits, showVotes, showPosts} + +type userVoteRow struct { + Title string + Href string + Rate string + At *time.Time +} + +type userPostRow struct { + Name string + Thread string + Article string + Href string + CreatedAt time.Time +} + +func (h *Handler) userActivity(w http.ResponseWriter, r *http.Request, loc *i18n.Localizer, rest string) error { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return nil + } + ctx := r.Context() + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + notFound(w) + return nil + } + row, err := h.deps.DB.AdminUser(ctx, siteID(ctx), id) + if errors.Is(err, db.ErrNotFound) { + notFound(w) + return nil + } + if err != nil { + return err + } + + show := r.URL.Query().Get("show") + if !contains(activityTabs, show) { + show = showEdits + } + page := atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + offset := (page - 1) * activityPerPage + + data := map[string]any{ + "Show": show, + "Tabs": activityTabs, + "Page": page, + "Action": Prefix + userSlug + "/" + rest + "/" + actionActivity, + "Back": Prefix + userSlug + "/" + rest, + } + var rows int + switch show { + case showVotes: + votes, err := h.userVotes(ctx, id, offset) + if err != nil { + return err + } + data["Votes"] = votes + data["Dated"] = grantsFrom(ctx).Has(perms.ViewVotesTimestamp) + rows = len(votes) + case showPosts: + posts, err := h.userPosts(ctx, id, offset) + if err != nil { + return err + } + data["Posts"] = posts + rows = len(posts) + default: + edits, err := h.userEdits(ctx, loc, id, offset) + if err != nil { + return err + } + data["Edits"] = edits + rows = len(edits) + } + data["More"] = rows == activityPerPage + + return h.page(w, r, loc, loc.T("admin.activity-of", "name", userLabel(row)), "user_activity.html", data) +} + +func userLabel(row db.AdminUserRow) string { + if row.Type == db.UserTypeWikidot && row.WikidotUsername != "" { + return row.WikidotUsername + } + return row.Username +} + +func (h *Handler) userEdits(ctx context.Context, loc *i18n.Localizer, id int64, offset int) ([]changeRow, error) { + hidden, err := repo.HiddenCategories(ctx, h.deps.DB, auth.FromContext(ctx)) + if err != nil { + return nil, err + } + found, err := h.deps.DB.SiteChanges(ctx, db.SiteChangeFilter{ + SiteID: siteID(ctx), + Hidden: hidden, + HasUser: true, + UserIDs: []int64{id}, + }, offset, activityPerPage) + if err != nil { + return nil, err + } + users := func(want []int64) ([]db.User, error) { return h.deps.DB.UsersByIDs(ctx, want) } + + out := make([]changeRow, 0, len(found)) + for _, c := range found { + article := db.Article{Category: c.ArticleCategory, Name: c.ArticleName, Title: c.ArticleTitle} + row := changeRow{ + Title: article.DisplayName(), + Href: "/" + article.FullName(), + CreatedAt: c.CreatedAt, + } + entry, err := changelog.Of(loc, users, c) + switch { + case errors.Is(err, changelog.ErrUnreadable): + case err != nil: + return nil, err + default: + row.Flags = entry.Flags + row.Comment = entry.Comment + } + out = append(out, row) + } + return out, nil +} + +func (h *Handler) userVotes(ctx context.Context, id int64, offset int) ([]userVoteRow, error) { + found, err := h.deps.DB.RatedBy(ctx, siteID(ctx), id, offset, activityPerPage) + if err != nil { + return nil, err + } + out := make([]userVoteRow, 0, len(found)) + for i := range found { + one := &found[i] + out = append(out, userVoteRow{ + Title: one.Article.DisplayName(), + Href: "/" + one.Article.FullName(), + Rate: strconv.FormatFloat(one.Rate, 'f', -1, 64), + At: one.VotedAt, + }) + } + return out, nil +} + +func (h *Handler) userPosts(ctx context.Context, id int64, offset int) ([]userPostRow, error) { + categories, err := h.deps.DB.ForumCategories(ctx, siteID(ctx)) + if err != nil { + return nil, err + } + ids := make([]int64, 0, len(categories)) + for _, c := range categories { + ids = append(ids, c.ID) + } + found, err := h.deps.DB.UserPosts(ctx, id, ids, true, offset, activityPerPage) + if err != nil { + return nil, err + } + + out := make([]userPostRow, 0, len(found)) + for _, p := range found { + row := userPostRow{ + Name: p.Name, + Thread: p.ThreadName, + Href: "/forum/t-" + strconv.FormatInt(p.ThreadID, 10) + + "#post-" + strconv.FormatInt(p.ID, 10), + CreatedAt: p.CreatedAt, + } + if p.ArticleTitle != nil || p.ArticleName != nil { + article := db.Article{} + if p.ArticleCategory != nil { + article.Category = *p.ArticleCategory + } + if p.ArticleName != nil { + article.Name = *p.ArticleName + } + if p.ArticleTitle != nil { + article.Title = *p.ArticleTitle + } + row.Article = article.DisplayName() + } + out = append(out, row) + } + return out, nil +} diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 00000000..6af26cbb --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,287 @@ +// Package archive reads the backup a wikitCLI run leaves behind. +package archive + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + metaDir = "meta" + pagesDir = "pages" + filesDir = "files" + usersDir = "_users" + siteFile = "site.json" + pageSufix = ".json" + sourceExt = ".7z" +) + +type Archive struct { + // sites maps each slug to the directory holding that site's meta, pages, + // files and forum. + sites map[string]string + // users are the _users directories that belong to no single site. + users []string +} + +type SiteMeta struct { + Slug string `json:"slug"` + Domain string `json:"domain"` + HomePage string `json:"home_page"` + Language string `json:"language"` + SiteID int64 `json:"site_id"` +} + +type User struct { + ID int64 `json:"user_id"` + Username string `json:"username"` + FullName string `json:"full_name"` + FetchedAt int64 `json:"fetched_at"` +} + +type Revision struct { + Number int `json:"revision"` + Author int64 `json:"author"` + Stamp int64 `json:"stamp"` + Flags string `json:"flags"` + Comment string `json:"commentary"` +} + +// HasSource reports whether the 7z holds the wikitext of this revision. Wikidot +// records a revision for a tag or a rename too, and those carry no text. +func (r Revision) HasSource() bool { + return strings.ContainsAny(r.Flags, "SN") +} + +func (r Revision) IsNew() bool { return strings.Contains(r.Flags, "N") } + +type File struct { + ID int64 `json:"file_id"` + Name string `json:"name"` + Mime string `json:"mime"` + Size int64 `json:"size_bytes"` + Author int64 `json:"author"` + Stamp int64 `json:"stamp"` +} + +type Vote struct { + UserID int64 + Value float64 +} + +// UnmarshalJSON reads the [user, value] pair the backup writes. A boolean is the +// up and down scale, a number is the star one. +func (v *Vote) UnmarshalJSON(data []byte) error { + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw) < 2 { + return fmt.Errorf("vote %s has %d fields, want 2", data, len(raw)) + } + if err := json.Unmarshal(raw[0], &v.UserID); err != nil { + return err + } + var flag bool + if err := json.Unmarshal(raw[1], &flag); err == nil { + v.Value = 1 + if !flag { + v.Value = -1 + } + return nil + } + return json.Unmarshal(raw[1], &v.Value) +} + +type Page struct { + // Stem is the name the meta and the source archive share, which is not the + // page name and is the only way back to the 7z. + Stem string `json:"-"` + + Name string `json:"name"` + Title string `json:"title"` + Parent string `json:"parent"` + Tags []string `json:"tags"` + Rating float64 `json:"rating"` + Locked bool `json:"is_locked"` + PageID int64 `json:"page_id"` + ThreadID int64 `json:"forum_thread"` + Revisions []Revision `json:"revisions"` + Votings []Vote `json:"votings"` + Files []File `json:"files"` +} + +// The path is either a site directory, recognised by the site meta inside it, +// or a directory holding several of them. +func Open(path string) (*Archive, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%q is a file, and pwikit reads the unpacked backup; unpack it and pass the directory", path) + } + a := &Archive{sites: map[string]string{}} + if isSite(path) { + a.sites[filepath.Base(path)] = path + } else { + entries, err := os.ReadDir(path) + if err != nil { + return nil, err + } + for _, e := range entries { + if !e.IsDir() || e.Name() == usersDir { + continue + } + if dir := filepath.Join(path, e.Name()); isSite(dir) { + a.sites[e.Name()] = dir + } + } + } + if len(a.sites) == 0 { + return nil, fmt.Errorf("no site under %q; a site directory holds %s, and the directory above several of them works too", + path, filepath.Join(metaDir, siteFile)) + } + if _, err := os.Stat(filepath.Join(path, usersDir)); err == nil { + a.users = append(a.users, filepath.Join(path, usersDir)) + } + return a, nil +} + +func isSite(dir string) bool { + _, err := os.Stat(filepath.Join(dir, metaDir, siteFile)) + return err == nil +} + +func (a *Archive) find(slug string, parts ...string) (string, bool) { + base, ok := a.sites[slug] + if !ok { + return "", false + } + full := filepath.Join(append([]string{base}, parts...)...) + if _, err := os.Stat(full); err != nil { + return "", false + } + return full, true +} + +// Sites lists the slugs the backup carries. A backup of one site holds one. +func (a *Archive) Sites() []string { + out := make([]string, 0, len(a.sites)) + for slug := range a.sites { + out = append(out, slug) + } + sort.Strings(out) + return out +} + +func (a *Archive) Site(slug string) (SiteMeta, error) { + var s SiteMeta + path, ok := a.find(slug, metaDir, siteFile) + if !ok { + return s, fmt.Errorf("no site %q in the archive", slug) + } + body, err := os.ReadFile(path) + if err != nil { + return s, err + } + if err := json.Unmarshal(body, &s); err != nil { + return s, fmt.Errorf("read the site meta of %q: %w", slug, err) + } + return s, nil +} + +// Users merges every _users directory the backup carries, the shared one and +// the one a site brought along. Where two disagree the newer fetch wins. +func (a *Archive) Users() (map[int64]User, error) { + dirs := append([]string{}, a.users...) + for _, slug := range a.Sites() { + dirs = append(dirs, filepath.Join(a.sites[slug], usersDir)) + } + + out := map[int64]User{} + for _, dir := range dirs { + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + continue + } + if err != nil { + return nil, err + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), pageSufix) || e.Name() == "pending.json" { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var bucket map[string]User + if err := json.Unmarshal(body, &bucket); err != nil { + return nil, fmt.Errorf("read users from %q: %w", e.Name(), err) + } + for _, u := range bucket { + if u.ID == 0 { + continue + } + if held, ok := out[u.ID]; ok && held.FetchedAt >= u.FetchedAt { + continue + } + out[u.ID] = u + } + } + } + return out, nil +} + +func (a *Archive) Pages(slug string) ([]Page, error) { + dir, ok := a.find(slug, metaDir, pagesDir) + if !ok { + return nil, fmt.Errorf("no pages for %q in the archive", slug) + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + out := make([]Page, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), pageSufix) { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var p Page + if err := json.Unmarshal(body, &p); err != nil { + return nil, fmt.Errorf("read the page meta %q: %w", e.Name(), err) + } + p.Stem = strings.TrimSuffix(e.Name(), pageSufix) + if p.Name == "" || len(p.Revisions) == 0 { + continue + } + out = append(out, p) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +func (a *Archive) sourcePath(slug, stem string) string { + path, _ := a.find(slug, pagesDir, stem+sourceExt) + return path +} + +// FilePath is where an attachment sits. Wikidot quotes the page name into the +// directory, so a colon arrives as %3A. +func (a *Archive) FilePath(slug, pageName string, fileID int64) string { + path, _ := a.find(slug, filesDir, quotePage(pageName), fmt.Sprint(fileID)) + return path +} + +func quotePage(name string) string { + return strings.ReplaceAll(name, ":", "%3A") +} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 00000000..b08dfa3f --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,133 @@ +package archive + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func site(t *testing.T, root, slug string) string { + t.Helper() + dir := filepath.Join(root, slug) + if err := os.MkdirAll(filepath.Join(dir, metaDir, pagesDir), 0o755); err != nil { + t.Fatal(err) + } + body := `{"slug":"` + slug + `","domain":"` + slug + `.wikidot.com","home_page":"start","site_id":7}` + if err := os.WriteFile(filepath.Join(dir, metaDir, siteFile), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestOpenReadsOneSiteDirectory(t *testing.T) { + root := t.TempDir() + dir := site(t, root, "my-wiki") + + found, err := Open(dir) + if err != nil { + t.Fatalf("Open(a site directory) err = %v, want nil", err) + } + if got := found.Sites(); !slices.Equal(got, []string{"my-wiki"}) { + t.Errorf("Sites() = %v, want [my-wiki]", got) + } + meta, err := found.Site("my-wiki") + if err != nil { + t.Fatalf("Site() err = %v, want nil", err) + } + if meta.Domain != "my-wiki.wikidot.com" { + t.Errorf("Site().Domain = %q, want %q", meta.Domain, "my-wiki.wikidot.com") + } +} + +func TestOpenReadsTheDirectoryAboveSeveralSites(t *testing.T) { + root := t.TempDir() + site(t, root, "second") + site(t, root, "first") + if err := os.MkdirAll(filepath.Join(root, usersDir), 0o755); err != nil { + t.Fatal(err) + } + body := `{"1":{"user_id":1,"username":"someone","full_name":"Some One","fetched_at":10}}` + if err := os.WriteFile(filepath.Join(root, usersDir, "1.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + found, err := Open(root) + if err != nil { + t.Fatalf("Open(a directory of sites) err = %v, want nil", err) + } + if got := found.Sites(); !slices.Equal(got, []string{"first", "second"}) { + t.Errorf("Sites() = %v, want [first second]", got) + } + users, err := found.Users() + if err != nil { + t.Fatalf("Users() err = %v, want nil", err) + } + if users[1].Username != "someone" { + t.Errorf("Users()[1].Username = %q, want %q", users[1].Username, "someone") + } +} + +func TestOpenRefusesAFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "my-wiki.tar.gz") + if err := os.WriteFile(path, []byte("not unpacked"), 0o644); err != nil { + t.Fatal(err) + } + _, err := Open(path) + if err == nil { + t.Fatal("Open(a file) err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "unpack") { + t.Errorf("Open(a file) err = %v, want it to say to unpack the backup", err) + } +} + +func TestImportPagesRefusesABackupWithoutAccounts(t *testing.T) { + dir := site(t, t.TempDir(), "my-wiki") + page := `{"name":"start","title":"Start","revisions":[{"revision":0,"author":42,"stamp":1600000000,"flags":"N"}]}` + if err := os.WriteFile(filepath.Join(dir, metaDir, pagesDir, "start.json"), []byte(page), 0o644); err != nil { + t.Fatal(err) + } + found, err := Open(dir) + if err != nil { + t.Fatal(err) + } + + _, err = ImportPages(context.Background(), nil, 1, found, "my-wiki", Options{}) + if !errors.Is(err, ErrNoAccounts) { + t.Errorf("ImportPages(no accounts) err = %v, want %v", err, ErrNoAccounts) + } +} + +func TestNamesAuthors(t *testing.T) { + cases := []struct { + name string + page Page + want bool + }{ + {"no authors", Page{Revisions: []Revision{{Author: 0}}}, false}, + {"revision author", Page{Revisions: []Revision{{Author: 0}, {Author: 42}}}, true}, + {"voter", Page{Revisions: []Revision{{}}, Votings: []Vote{{UserID: 42, Value: 1}}}, true}, + {"uploader", Page{Revisions: []Revision{{}}, Files: []File{{Author: 42}}}, true}, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + if got := namesAuthors([]Page{tt.page}); got != tt.want { + t.Errorf("namesAuthors(%+v) = %t, want %t", tt.page, got, tt.want) + } + }) + } +} + +func TestOpenRefusesADirectoryWithNoSite(t *testing.T) { + _, err := Open(t.TempDir()) + if err == nil { + t.Fatal("Open(an empty directory) err = nil, want non-nil") + } + if !strings.Contains(err.Error(), filepath.Join(metaDir, siteFile)) { + t.Errorf("Open(an empty directory) err = %v, want it to name %q", err, filepath.Join(metaDir, siteFile)) + } +} diff --git a/internal/archive/files.go b/internal/archive/files.go new file mode 100644 index 00000000..98cca81e --- /dev/null +++ b/internal/archive/files.go @@ -0,0 +1,81 @@ +package archive + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/media" +) + +func (im *importer) importFiles(ctx context.Context, page Page, articleID int64, articleMedia string) (int, error) { + if im.opts.Files == "" || len(page.Files) == 0 { + return 0, nil + } + written := 0 + for _, file := range page.Files { + from := im.archive.FilePath(im.slug, page.Name, file.ID) + if from == "" { + report(im.opts, fmt.Sprintf("missing attachment %s/%s", page.Name, file.Name)) + continue + } + stored, err := storedName(file.Name) + if err != nil { + return written, err + } + to := filepath.Join(im.opts.Files, "media", + media.QuoteName(articleMedia), media.QuoteName(stored)) + if err := copyFile(from, to); err != nil { + return written, err + } + _, err = im.db.AddArticleFile(ctx, db.FileWrite{ + ArticleID: articleID, + Name: file.Name, + MediaName: stored, + MimeType: file.Mime, + Size: file.Size, + AuthorID: localUser(im.users, file.Author), + At: time.Unix(file.Stamp, 0).UTC(), + }) + if err != nil { + return written, err + } + written++ + } + return written, nil +} + +// The stored name keeps the extension and nothing else, because the name a +// visitor typed is answered from the database rather than from the disk. +func storedName(name string) (string, error) { + unique, err := db.MediaName() + if err != nil { + return "", err + } + return unique + filepath.Ext(name), nil +} + +func copyFile(from, to string) error { + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return err + } + in, err := os.Open(from) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(to) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} diff --git a/internal/archive/forum.go b/internal/archive/forum.go new file mode 100644 index 00000000..dded1c5f --- /dev/null +++ b/internal/archive/forum.go @@ -0,0 +1,134 @@ +package archive + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/bodgit/sevenzip" +) + +const ( + forumDir = "forum" + categoryDir = "category" + postBodyFile = "latest.html" +) + +type Category struct { + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description"` +} + +type Thread struct { + ID int64 `json:"id"` + CategoryID int64 `json:"-"` + Title string `json:"title"` + Description string `json:"description"` + Started int64 `json:"started"` + StartedUser *int64 `json:"startedUser"` + Last int64 `json:"last"` + Sticky bool `json:"sticky"` + Locked bool `json:"isLocked"` + Posts []Post `json:"posts"` +} + +type Post struct { + ID int64 `json:"id"` + Title string `json:"title"` + Poster int64 `json:"poster"` + Stamp int64 `json:"stamp"` + Revisions []PostRevision `json:"revisions"` + Children []Post `json:"children"` +} + +type PostRevision struct { + ID int64 `json:"id"` + Title string `json:"title"` + Author int64 `json:"author"` + Stamp int64 `json:"stamp"` +} + +func (a *Archive) Categories(slug string) ([]Category, error) { + dir, ok := a.find(slug, metaDir, forumDir, categoryDir) + if !ok { + return nil, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []Category + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), pageSufix) { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var c Category + if err := json.Unmarshal(body, &c); err != nil { + return nil, fmt.Errorf("read the forum category %q: %w", e.Name(), err) + } + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (a *Archive) Threads(slug string, categoryID int64) ([]Thread, error) { + dir, ok := a.find(slug, metaDir, forumDir, strconv.FormatInt(categoryID, 10)) + if !ok { + return nil, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []Thread + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), pageSufix) { + continue + } + body, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + var t Thread + if err := json.Unmarshal(body, &t); err != nil { + return nil, fmt.Errorf("read the forum thread %q: %w", e.Name(), err) + } + t.CategoryID = categoryID + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { return out[i].Started < out[j].Started }) + return out, nil +} + +func (a *Archive) PostBodies(slug string, thread Thread) (map[string]string, error) { + out := map[string]string{} + path, ok := a.find(slug, forumDir, strconv.FormatInt(thread.CategoryID, 10), + strconv.FormatInt(thread.ID, 10)+sourceExt) + if !ok { + return out, nil + } + reader, err := sevenzip.OpenReader(path) + if err != nil { + return out, nil + } + defer reader.Close() + + for _, entry := range reader.File { + body, err := readEntry(entry) + if err != nil { + return nil, fmt.Errorf("read %s of thread %d: %w", entry.Name, thread.ID, err) + } + out[filepath.ToSlash(entry.Name)] = body + } + return out, nil +} diff --git a/internal/archive/forum_import.go b/internal/archive/forum_import.go new file mode 100644 index 00000000..75263312 --- /dev/null +++ b/internal/archive/forum_import.go @@ -0,0 +1,153 @@ +package archive + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/htmlsource" +) + +const importedSection = "Imported" + +// A thread carries no name a second run could recognise, so a site that already +// has a section is left alone rather than doubled. +func (im *importer) importForum(ctx context.Context, byArchiveThread map[int64]int64, out *Result) error { + categories, err := im.archive.Categories(im.slug) + if err != nil || len(categories) == 0 { + return err + } + sections, err := im.db.AdminForumSections(ctx, im.siteID) + if err != nil { + return err + } + if len(sections) > 0 { + report(im.opts, "the site already has a forum, leaving it alone") + return nil + } + + threads := make(map[int64][]Thread, len(categories)) + for _, category := range categories { + found, err := im.archive.Threads(im.slug, category.ID) + if err != nil { + return err + } + threads[category.ID] = found + } + + sectionID, err := im.db.ImportForumSection(ctx, im.siteID, importedSection, "") + if err != nil { + return err + } + + for order, category := range categories { + comments := 0 + for _, thread := range threads[category.ID] { + if _, ok := byArchiveThread[thread.ID]; ok { + comments++ + } + } + localCategory, err := im.db.ImportForumCategory(ctx, im.siteID, sectionID, + category.Title, category.Description, order, comments*2 > len(threads[category.ID])) + if err != nil { + return err + } + out.Categories++ + + for _, thread := range threads[category.ID] { + posts, err := im.importThread(ctx, thread, localCategory, byArchiveThread) + if err != nil { + return err + } + out.Threads++ + out.Posts += posts + if out.Threads%100 == 0 { + report(im.opts, fmt.Sprintf("%d threads, %d posts", out.Threads, out.Posts)) + } + } + } + return nil +} + +func (im *importer) importThread(ctx context.Context, thread Thread, categoryID int64, + byArchiveThread map[int64]int64) (int, error) { + + bodies, err := im.archive.PostBodies(im.slug, thread) + if err != nil { + return 0, err + } + posts, parents := im.flatten(thread.Posts, -1, nil, nil, bodies) + + write := db.ImportThread{ + CategoryID: &categoryID, + Name: thread.Title, + Description: thread.Description, + AuthorID: localUser(im.users, started(thread)), + CreatedAt: time.Unix(thread.Started, 0).UTC(), + UpdatedAt: time.Unix(thread.Last, 0).UTC(), + Pinned: thread.Sticky, + Locked: thread.Locked, + } + if article, ok := byArchiveThread[thread.ID]; ok { + write.CategoryID = nil + write.ArticleID = &article + } + return im.db.ImportForumThread(ctx, im.siteID, write, posts, parents) +} + +func (im *importer) flatten(tree []Post, parent int, posts []db.ImportPost, parents []int, + bodies map[string]string) ([]db.ImportPost, []int) { + + for _, post := range tree { + posts = append(posts, db.ImportPost{ + Name: post.Title, + AuthorID: localUser(im.users, post.Poster), + CreatedAt: time.Unix(post.Stamp, 0).UTC(), + Versions: im.versions(post, bodies), + }) + parents = append(parents, parent) + posts, parents = im.flatten(post.Children, len(posts)-1, posts, parents, bodies) + } + return posts, parents +} + +func (im *importer) versions(post Post, bodies map[string]string) []db.ImportPostVersion { + if len(post.Revisions) == 0 { + return []db.ImportPostVersion{{ + Source: body(bodies, fmt.Sprintf("%d/%s", post.ID, postBodyFile)), + AuthorID: localUser(im.users, post.Poster), + At: time.Unix(post.Stamp, 0).UTC(), + }} + } + revisions := append([]PostRevision(nil), post.Revisions...) + sort.SliceStable(revisions, func(i, j int) bool { return revisions[i].Stamp < revisions[j].Stamp }) + + out := make([]db.ImportPostVersion, 0, len(revisions)) + for _, revision := range revisions { + out = append(out, db.ImportPostVersion{ + Source: body(bodies, fmt.Sprintf("%d/%d.html", post.ID, revision.ID)), + AuthorID: localUser(im.users, revision.Author), + At: time.Unix(revision.Stamp, 0).UTC(), + }) + } + return out +} + +// The archive pads a post with the layout whitespace it was served inside, and +// wikitext reads leading whitespace as markup. +func body(bodies map[string]string, key string) string { + return strings.TrimSpace(htmlsource.Convert(bodies[key])) +} + +func started(thread Thread) int64 { + if thread.StartedUser != nil { + return *thread.StartedUser + } + if len(thread.Posts) > 0 { + return thread.Posts[0].Poster + } + return 0 +} diff --git a/internal/archive/forum_import_test.go b/internal/archive/forum_import_test.go new file mode 100644 index 00000000..058dc15c --- /dev/null +++ b/internal/archive/forum_import_test.go @@ -0,0 +1,93 @@ +package archive + +import ( + "testing" +) + +func TestFlattenNamesEveryParentAheadOfItsChildren(t *testing.T) { + tree := []Post{ + {ID: 1, Title: "a", Children: []Post{ + {ID: 2, Title: "b", Children: []Post{{ID: 3, Title: "c"}}}, + {ID: 4, Title: "d"}, + }}, + {ID: 5, Title: "e"}, + } + im := &importer{} + posts, parents := im.flatten(tree, -1, nil, nil, map[string]string{}) + + wantNames := []string{"a", "b", "c", "d", "e"} + if len(posts) != len(wantNames) { + t.Fatalf("len(flatten) = %d, want %d", len(posts), len(wantNames)) + } + for i, want := range wantNames { + if posts[i].Name != want { + t.Errorf("flatten()[%d].Name = %q, want %q", i, posts[i].Name, want) + } + } + wantParents := []int{-1, 0, 1, 0, -1} + for i, want := range wantParents { + if parents[i] != want { + t.Errorf("flatten() parent of %q = %d, want %d", posts[i].Name, parents[i], want) + } + if parents[i] >= i { + t.Errorf("flatten() parent of %q = %d, want less than %d", posts[i].Name, parents[i], i) + } + } +} + +func TestVersionsReadOldestFirst(t *testing.T) { + post := Post{ + ID: 7, + Stamp: 100, + Revisions: []PostRevision{ + {ID: 30, Stamp: 300}, + {ID: 20, Stamp: 200}, + {ID: 10, Stamp: 100}, + }, + } + bodies := map[string]string{ + "7/10.html": "

      one

      ", + "7/20.html": "

      two

      ", + "7/30.html": "

      three

      ", + "7/latest.html": "

      three

      ", + } + im := &importer{} + got := im.versions(post, bodies) + + want := []string{"one", "two", "three"} + if len(got) != len(want) { + t.Fatalf("len(versions) = %d, want %d", len(got), len(want)) + } + for i, text := range want { + if got[i].Source != text { + t.Errorf("versions()[%d].Source = %q, want %q", i, got[i].Source, text) + } + if got[i].At.Unix() != int64(100*(i+1)) { + t.Errorf("versions()[%d].At = %d, want %d", i, got[i].At.Unix(), 100*(i+1)) + } + } +} + +func TestVersionsFallsBackToTheLatestBody(t *testing.T) { + im := &importer{} + got := im.versions(Post{ID: 7, Stamp: 100}, map[string]string{"7/latest.html": "\n\t

      only

      \n"}) + if len(got) != 1 { + t.Fatalf("len(versions) = %d, want 1", len(got)) + } + if got[0].Source != "only" { + t.Errorf("versions()[0].Source = %q, want %q", got[0].Source, "only") + } +} + +func TestStartedFallsBackToTheFirstPoster(t *testing.T) { + author := int64(11) + if got := started(Thread{StartedUser: &author}); got != 11 { + t.Errorf("started(with startedUser) = %d, want 11", got) + } + if got := started(Thread{Posts: []Post{{Poster: 22}}}); got != 22 { + t.Errorf("started(without startedUser) = %d, want 22", got) + } + if got := started(Thread{}); got != 0 { + t.Errorf("started(empty) = %d, want 0", got) + } +} diff --git a/internal/archive/import.go b/internal/archive/import.go new file mode 100644 index 00000000..b0a5c4d4 --- /dev/null +++ b/internal/archive/import.go @@ -0,0 +1,249 @@ +package archive + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/wikidot" +) + +type Options struct { + // ForceTags creates tags the site would otherwise refuse, which is what an + // import of somebody else's wiki almost always needs. + ForceTags bool + + // Votes is off when only the text is wanted, since ratings are the part an + // owner most often means to start over. + Votes bool + + // Files is the state directory attachments are copied into. Empty leaves + // them behind. + Files string + + // Report is called with a line worth showing while a long import runs. + Report func(string) + + WithoutAccounts bool +} + +var ErrNoAccounts = errors.New("the backup names authors but holds no accounts") + +type Result struct { + Users int + Pages int + Skipped int + Revisions int + Parents int + Files int + + Categories int + Threads int + Posts int +} + +type importer struct { + db *db.DB + archive *Archive + slug string + siteID int64 + opts Options + users map[int64]int64 +} + +// ImportPages writes the pages of one site in the archive, skipping any page the +// database already has. Nothing already here is edited. +func ImportPages(ctx context.Context, d *db.DB, siteID int64, a *Archive, slug string, opts Options) (Result, error) { + var out Result + im := &importer{db: d, archive: a, slug: slug, siteID: siteID, opts: opts} + + pages, err := a.Pages(slug) + if err != nil { + return out, err + } + im.users, err = im.importUsers(ctx) + if err != nil { + return out, err + } + if len(im.users) == 0 && !opts.WithoutAccounts && namesAuthors(pages) { + return out, ErrNoAccounts + } + out.Users = len(im.users) + report(opts, fmt.Sprintf("%d accounts, %d pages", out.Users, len(pages))) + + local := make(map[string]int64, len(pages)) + byThread := map[int64]int64{} + for _, page := range pages { + existing, err := d.ArticleByName(ctx, siteID, page.Name) + if err == nil { + out.Skipped++ + local[wikidot.Normalize(page.Name)] = existing.ID + byThread[page.ThreadID] = existing.ID + continue + } + if !errors.Is(err, db.ErrNotFound) { + return out, err + } + + id, media, revisions, err := im.importPage(ctx, page) + if err != nil { + return out, err + } + local[wikidot.Normalize(page.Name)] = id + byThread[page.ThreadID] = id + out.Pages++ + out.Revisions += revisions + + files, err := im.importFiles(ctx, page, id, media) + if err != nil { + return out, err + } + out.Files += files + + if out.Pages%200 == 0 { + report(opts, fmt.Sprintf("%d of %d pages", out.Pages, len(pages))) + } + } + + for _, page := range pages { + if page.Parent == "" { + continue + } + child, ok := local[wikidot.Normalize(page.Name)] + if !ok { + continue + } + parent, ok := local[wikidot.Normalize(page.Parent)] + if !ok { + continue + } + if err := d.SetImportedParent(ctx, siteID, child, parent); err != nil { + return out, err + } + out.Parents++ + } + + delete(byThread, 0) + if err := im.importForum(ctx, byThread, &out); err != nil { + return out, err + } + return out, nil +} + +func namesAuthors(pages []Page) bool { + for _, page := range pages { + for _, rev := range page.Revisions { + if rev.Author > 0 { + return true + } + } + for _, vote := range page.Votings { + if vote.UserID > 0 { + return true + } + } + for _, file := range page.Files { + if file.Author > 0 { + return true + } + } + } + return false +} + +func (im *importer) importUsers(ctx context.Context) (map[int64]int64, error) { + found, err := im.archive.Users() + if err != nil { + return nil, err + } + list := make([]db.ImportUser, 0, len(found)) + for _, u := range found { + list = append(list, db.ImportUser{ + WikidotID: u.ID, + Username: u.Username, + DisplayName: u.FullName, + }) + } + return im.db.EnsureWikidotUsers(ctx, list, time.Now().UTC()) +} + +func (im *importer) importPage(ctx context.Context, page Page) (int64, string, int, error) { + sources, err := im.archive.Sources(im.slug, page) + if err != nil { + return 0, "", 0, err + } + + category, name := wikidot.Split(wikidot.Normalize(page.Name)) + oldest := page.Revisions[len(page.Revisions)-1] + newest := page.Revisions[0] + + write := db.ImportArticle{ + Category: category, + Name: name, + Title: page.Title, + Locked: page.Locked, + CreatedAt: time.Unix(oldest.Stamp, 0).UTC(), + UpdatedAt: time.Unix(newest.Stamp, 0).UTC(), + AuthorID: localUser(im.users, oldest.Author), + } + + // The archive lists the newest revision first and the history reads the + // other way round. + for i := len(page.Revisions) - 1; i >= 0; i-- { + rev := page.Revisions[i] + one := db.ImportRevision{ + Number: rev.Number, + UserID: localUser(im.users, rev.Author), + Comment: rev.Comment, + At: time.Unix(rev.Stamp, 0).UTC(), + IsNew: rev.IsNew(), + } + if source, ok := sources[rev.Number]; ok { + one.Source = &source + } + write.Revisions = append(write.Revisions, one) + if one.Source != nil { + write.Indexed = *one.Source + } + } + + if im.opts.Votes { + for _, vote := range page.Votings { + user := localUser(im.users, vote.UserID) + if user == nil { + continue + } + write.Votes = append(write.Votes, db.ImportVote{UserID: *user, Rate: vote.Value}) + } + } + if len(page.Tags) > 0 { + ids, err := im.db.EnsureTags(ctx, im.siteID, page.Tags, im.opts.ForceTags) + if err != nil { + return 0, "", 0, err + } + write.TagIDs = ids + } + + id, media, err := im.db.ImportArticle(ctx, im.siteID, write) + if err != nil { + return 0, "", 0, err + } + return id, media, len(write.Revisions), nil +} + +// An author the archive never described is left off rather than invented, which +// shows the revision as the system's own. +func localUser(byWikidot map[int64]int64, wikidotID int64) *int64 { + if id, ok := byWikidot[wikidotID]; ok { + return &id + } + return nil +} + +func report(opts Options, line string) { + if opts.Report != nil { + opts.Report(line) + } +} diff --git a/internal/archive/source.go b/internal/archive/source.go new file mode 100644 index 00000000..2a203e3f --- /dev/null +++ b/internal/archive/source.go @@ -0,0 +1,58 @@ +package archive + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/bodgit/sevenzip" +) + +// Sources reads the wikitext of every revision that carries one, keyed by +// revision number. A page whose archive is missing comes back empty rather than +// failing, which is what the backup does when a page was never fetched. +func (a *Archive) Sources(slug string, page Page) (map[int]string, error) { + wanted := map[string]int{} + for _, rev := range page.Revisions { + if rev.HasSource() { + wanted[strconv.Itoa(rev.Number)+".txt"] = rev.Number + } + } + out := make(map[int]string, len(wanted)) + if len(wanted) == 0 { + return out, nil + } + + reader, err := sevenzip.OpenReader(a.sourcePath(slug, page.Stem)) + if err != nil { + return out, nil + } + defer reader.Close() + + for _, entry := range reader.File { + number, ok := wanted[strings.ToLower(entry.Name)] + if !ok { + continue + } + body, err := readEntry(entry) + if err != nil { + return nil, fmt.Errorf("read revision %d of %q: %w", number, page.Name, err) + } + out[number] = body + } + return out, nil +} + +func readEntry(entry *sevenzip.File) (string, error) { + rc, err := entry.Open() + if err != nil { + return "", err + } + defer rc.Close() + body, err := io.ReadAll(rc) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/internal/article/params_test.go b/internal/article/params_test.go new file mode 100644 index 00000000..a6f3aaf8 --- /dev/null +++ b/internal/article/params_test.go @@ -0,0 +1,105 @@ +package article + +import "testing" + +func TestUnquoteDecodesEscapes(t *testing.T) { + cases := map[string]string{ + "": "", + "plain": "plain", + "%E4%B8%AD": "中", + "%F0%9F%98%80": "😀", + "%00": "\x00", + "+x": "+x", + "a%2Fb": "a/b", + "%2f": "/", + } + for in, want := range cases { + if got := unquote(in); got != want { + t.Errorf("unquote(%q) = %q, want %q", in, got, want) + } + } +} + +func TestUnquoteKeepsUnreadableEscapes(t *testing.T) { + cases := map[string]string{ + "%ZZ": "%ZZ", + "%": "%", + "a%2": "a%2", + "%%": "%%", + } + for in, want := range cases { + if got := unquote(in); got != want { + t.Errorf("unquote(%q) = %q, want %q", in, got, want) + } + } +} + +func TestUnquoteSpendsOneReplacementPerSubpart(t *testing.T) { + cases := map[string]int{ + "%FF%FF": 2, + "%E4%B8": 1, + "%E4%B8%41": 1, + "%C3": 1, + "%ED%A0%80": 3, + "%F0%9F": 1, + } + for in, want := range cases { + got := 0 + for _, r := range unquote(in) { + if r == '�' { + got++ + } + } + if got != want { + t.Errorf("replacements in unquote(%q) = %d, want %d", in, got, want) + } + } +} + +func TestParamsPutKeepsFirstPosition(t *testing.T) { + _, params := ParsePath("main/a/1/b/2/a/3", "") + if got, want := len(params), 2; got != want { + t.Fatalf("len(params) = %d, want %d", got, want) + } + if got, want := params[0].Key, "a"; got != want { + t.Errorf("params[0].Key = %q, want %q", got, want) + } + if got, want := params[0].Value, "3"; got != want { + t.Errorf("params[0].Value = %q, want %q", got, want) + } +} + +func TestParamsGetAnswersEmptyForBareKey(t *testing.T) { + _, params := ParsePath("main/norender", "") + if got, want := params.Get("norender"), ""; got != want { + t.Errorf("Get(%q) = %q, want %q", "norender", got, want) + } +} + +func TestParamsGetAnswersEmptyForMissingKey(t *testing.T) { + _, params := ParsePath("main/a/1", "") + if got, want := params.Get("b"), ""; got != want { + t.Errorf("Get(%q) = %q, want %q", "b", got, want) + } +} + +func TestParamsEncodeKeepsOnlyOneBareKey(t *testing.T) { + params := Params{{Key: "z", Value: "1"}, {Key: "first", Bare: true}, {Key: "a", Value: "2"}, {Key: "second", Bare: true}} + if got, want := Encode(params), "/a/2/z/1/first"; got != want { + t.Errorf("Encode() = %q, want %q", got, want) + } +} + +func TestParsePathFallsBackToHomePage(t *testing.T) { + name, _ := ParsePath("", "start") + if got, want := name, "start"; got != want { + t.Errorf("ParsePath(%q, %q) name = %q, want %q", "", "start", got, want) + } +} + +func TestParsePathFallsBackToMainWithoutHomePage(t *testing.T) { + name, _ := ParsePath("", " ") + if got, want := name, "main"; got != want { + t.Errorf("ParsePath(%q, %q) name = %q, want %q", "", " ", got, want) + } +} diff --git a/internal/article/path.go b/internal/article/path.go new file mode 100644 index 00000000..48f84c87 --- /dev/null +++ b/internal/article/path.go @@ -0,0 +1,136 @@ +// Package article turns a request path into the page it names and the +// parameters riding along with it. +package article + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/wikidot" +) + +const defaultHomePage = "main" + +type Param = page.PathParam + +type Params = page.PathParams + +var ( + forumStart = regexp.MustCompile(`^forum/start(.*)$`) + forumCategory = regexp.MustCompile(`^forum/c-(\d+)(.*)$`) + forumThread = regexp.MustCompile(`^forum/t-(\d+)(.*)$`) + forumSection = regexp.MustCompile(`^forum/s-(\d+)(.*)$`) +) + +// ParsePath reads the path with the leading slash already gone. homePage is +// the site's own setting, which only an empty name reaches for. +func ParsePath(raw, homePage string) (string, Params) { + segments := strings.Split(rewriteForum(raw), "/") + + name := strings.TrimSpace(unquote(segments[0])) + if name == "" { + name = strings.TrimSpace(homePage) + } + if name == "" { + name = defaultHomePage + } + + var params Params + rest := segments[1:] + for i := 0; i < len(rest); i += 2 { + key := strings.ToLower(unquote(rest[i])) + value, bare := "", true + if i+1 < len(rest) { + value, bare = unquote(rest[i+1]), false + } + if key == "" && value == "" { + continue + } + params = params.Put(Param{Key: key, Value: value, Bare: bare}) + } + return name, params +} + +// rewriteForum turns the four Wikidot forum URLs into the pages that answer +// them. Only the first match can apply, since each rewrite drops the prefix +// the others need. +func rewriteForum(path string) string { + if m := forumStart.FindStringSubmatch(path); m != nil { + return "forum:start" + m[1] + } + if m := forumCategory.FindStringSubmatch(path); m != nil { + return "forum:category/c/" + m[1] + m[2] + } + if m := forumThread.FindStringSubmatch(path); m != nil { + return "forum:thread/t/" + m[1] + m[2] + } + if m := forumSection.FindStringSubmatch(path); m != nil { + return "forum:start/s/" + m[1] + m[2] + } + return path +} + +// Encode writes the parameters back as a path, sorted by key. Only the values +// are escaped, and only the first bare key survives. +func Encode(p Params) string { + var named []Param + bare := "" + hasBare := false + for _, param := range p { + if param.Bare { + if !hasBare { + bare, hasBare = param.Key, true + } + continue + } + named = append(named, param) + } + slices.SortFunc(named, func(a, b Param) int { return strings.Compare(a.Key, b.Key) }) + + var b strings.Builder + for _, param := range named { + b.WriteString("/" + param.Key + "/" + page.QuoteAll(param.Value)) + } + if hasBare { + b.WriteString("/" + bare) + } + return b.String() +} + +// RedirectTarget is the Location a request gets when it names a page by +// anything other than the normalized name. ok is false when the name already +// is one. +func RedirectTarget(name string, params Params) (target string, ok bool) { + normalized := wikidot.Normalize(name) + if normalized == name { + return "", false + } + // Escaped once more on the way out because Encode leaves the keys alone, + // and a key may hold anything the path did. + return iriToURI("/" + normalized + Encode(params)), true +} + +// iriSafe spares the percent sign on top of the unreserved characters, so text +// escaped once does not grow a second time. +const iriSafe = "/#%[]=:;$&()+,!*?@'" + +func iriToURI(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9': + b.WriteByte(c) + case c == '_' || c == '.' || c == '-' || c == '~': + b.WriteByte(c) + case strings.IndexByte(iriSafe, c) >= 0: + b.WriteByte(c) + default: + fmt.Fprintf(&b, "%%%02X", c) + } + } + return b.String() +} diff --git a/internal/article/path_test.go b/internal/article/path_test.go new file mode 100644 index 00000000..c091ffea --- /dev/null +++ b/internal/article/path_test.go @@ -0,0 +1,195 @@ +package article + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/page" +) + +var update = flag.Bool("update", false, "rewrite the golden files and export the corpus") + +var pathCorpus = []string{ + "", + "/", + "main", + "main/", + "main/a/1", + "main/a/1/", + "main/a/", + "main/norender", + "main/a/1/b", + "main/A/1/B/2", + "main/b/2/a/1", + "main/a/1/a/2", + "main//x", + " main ", + "MAIN/A/B", + "main/a/%2Fb", + "main/%20x/%20y", + "main/uni/%E4%B8%AD", + "main/bad/%FF%FF", + "main/bad/%E4%B8", + "main/bad/%ZZ", + "scp-173/norender/true", + "%D1%80%D1%83%D1%81/a/1", + "forum/start", + "forum/start/x/1", + "forum/c-12/x/1", + "forum/t-7", + "forum/s-3/a/b", + "forum/other", +} + +var thisPageParams = Params{ + {Key: "a", Value: "1"}, + {Key: "norender", Bare: true}, + {Key: "q", Value: "x y/z"}, + {Key: "uni", Value: "中"}, +} + +var thisPageNames = []string{ + "path|a", + "path|A", + "path|missing", + "path|norender", + "path|", + "path_expr|a", + "path_expr|missing", + "path_expr|norender", + "path_expr|uni", + "path_expr|q", + "path_url|q", + "path_url|missing", + "path_url|norender", + "path_url|uni", + "canonical_url", + "PATH|a", + "other", +} + +var redirectCorpus = []string{ + "main", + "main/a/1", + "Main", + "Main/", + "Main/a/1/", + "Main/a/", + "Main/b/2/a/1", + "Main/A/1/B/2", + "Main/norender/true/foo", + "Main/x/y%20z", + "Main//x", + "Main/a/%2Fb", + "Main/%20x/1", + "%D1%80%D1%83%D1%81/a/1", + "Some%20Page/a/1", + "SCP-173", +} + +const canonicalURL = "https://wiki.example/main/a/1" + +func TestParsePathMatchesGolden(t *testing.T) { + var b strings.Builder + for _, raw := range pathCorpus { + name, params := ParsePath(raw, "") + fmt.Fprintf(&b, "=== %s\nname %q\n", raw, name) + for _, param := range params { + if param.Bare { + fmt.Fprintf(&b, "param %q = \n", param.Key) + continue + } + fmt.Fprintf(&b, "param %q = %q\n", param.Key, param.Value) + } + } + checkGolden(t, "path.golden", b.String()) + if *update { + writeCorpus(t, "path_corpus.json", pathCorpus) + } +} + +func TestThisPageMatchesGolden(t *testing.T) { + resolve := ThisPage(thisPageParams, canonicalURL) + var b strings.Builder + for _, name := range thisPageNames { + fmt.Fprintf(&b, "%s -> %q\n", name, page.ApplyTemplate("%%"+name+"%%", resolve)) + } + checkGolden(t, "thispage.golden", b.String()) + if *update { + corpus := map[string]any{"params": paramsForCorpus(thisPageParams), "names": thisPageNames, "canonical_url": canonicalURL} + writeCorpus(t, "thispage_corpus.json", corpus) + } +} + +func TestRedirectTargetMatchesGolden(t *testing.T) { + var b strings.Builder + for _, raw := range redirectCorpus { + name, params := ParsePath(raw, "") + target, ok := RedirectTarget(name, params) + if !ok { + target = "" + } + fmt.Fprintf(&b, "%s -> %s\n", raw, target) + } + checkGolden(t, "redirect.golden", b.String()) + if *update { + writeCorpus(t, "redirect_corpus.json", redirectCorpus) + } +} + +func paramsForCorpus(params Params) []map[string]any { + out := make([]map[string]any, 0, len(params)) + for _, param := range params { + entry := map[string]any{"key": param.Key, "value": param.Value} + if param.Bare { + entry["value"] = nil + } + out = append(out, entry) + } + return out +} + +func checkGolden(t *testing.T, name, got string) { + t.Helper() + path := filepath.Join("testdata", name) + if *update { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("WriteFile(%q) = %v, want nil", path, err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q) = %v, want nil", path, err) + } + if got != string(want) { + t.Errorf("%s differs from the golden file; first difference at %s", name, firstDiff(got, string(want))) + } +} + +func firstDiff(got, want string) string { + gotLines, wantLines := strings.Split(got, "\n"), strings.Split(want, "\n") + for i := 0; i < len(gotLines) && i < len(wantLines); i++ { + if gotLines[i] != wantLines[i] { + return fmt.Sprintf("line %d: got %q, want %q", i+1, gotLines[i], wantLines[i]) + } + } + return fmt.Sprintf("line count: got %d, want %d", len(gotLines), len(wantLines)) +} + +func writeCorpus(t *testing.T, name string, value any) { + t.Helper() + raw, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("MarshalIndent() = %v, want nil", err) + } + path := filepath.Join("testdata", name) + if err := os.WriteFile(path, append(raw, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile(%q) = %v, want nil", path, err) + } +} diff --git a/internal/article/testdata/path.golden b/internal/article/testdata/path.golden new file mode 100644 index 00000000..3165083a --- /dev/null +++ b/internal/article/testdata/path.golden @@ -0,0 +1,86 @@ +=== +name "main" +=== / +name "main" +=== main +name "main" +=== main/ +name "main" +=== main/a/1 +name "main" +param "a" = "1" +=== main/a/1/ +name "main" +param "a" = "1" +=== main/a/ +name "main" +param "a" = "" +=== main/norender +name "main" +param "norender" = +=== main/a/1/b +name "main" +param "a" = "1" +param "b" = +=== main/A/1/B/2 +name "main" +param "a" = "1" +param "b" = "2" +=== main/b/2/a/1 +name "main" +param "b" = "2" +param "a" = "1" +=== main/a/1/a/2 +name "main" +param "a" = "2" +=== main//x +name "main" +param "" = "x" +=== main +name "main" +=== MAIN/A/B +name "MAIN" +param "a" = "B" +=== main/a/%2Fb +name "main" +param "a" = "/b" +=== main/%20x/%20y +name "main" +param " x" = " y" +=== main/uni/%E4%B8%AD +name "main" +param "uni" = "中" +=== main/bad/%FF%FF +name "main" +param "bad" = "��" +=== main/bad/%E4%B8 +name "main" +param "bad" = "�" +=== main/bad/%ZZ +name "main" +param "bad" = "%ZZ" +=== scp-173/norender/true +name "scp-173" +param "norender" = "true" +=== %D1%80%D1%83%D1%81/a/1 +name "рус" +param "a" = "1" +=== forum/start +name "forum:start" +=== forum/start/x/1 +name "forum:start" +param "x" = "1" +=== forum/c-12/x/1 +name "forum:category" +param "c" = "12" +param "x" = "1" +=== forum/t-7 +name "forum:thread" +param "t" = "7" +=== forum/s-3/a/b +name "forum:start" +param "s" = "3" +param "a" = "b" +=== forum/other +name "forum" +param "other" = diff --git a/internal/article/testdata/path_corpus.json b/internal/article/testdata/path_corpus.json new file mode 100644 index 00000000..1833a103 --- /dev/null +++ b/internal/article/testdata/path_corpus.json @@ -0,0 +1,31 @@ +[ + "", + "/", + "main", + "main/", + "main/a/1", + "main/a/1/", + "main/a/", + "main/norender", + "main/a/1/b", + "main/A/1/B/2", + "main/b/2/a/1", + "main/a/1/a/2", + "main//x", + " main ", + "MAIN/A/B", + "main/a/%2Fb", + "main/%20x/%20y", + "main/uni/%E4%B8%AD", + "main/bad/%FF%FF", + "main/bad/%E4%B8", + "main/bad/%ZZ", + "scp-173/norender/true", + "%D1%80%D1%83%D1%81/a/1", + "forum/start", + "forum/start/x/1", + "forum/c-12/x/1", + "forum/t-7", + "forum/s-3/a/b", + "forum/other" +] diff --git a/internal/article/testdata/redirect.golden b/internal/article/testdata/redirect.golden new file mode 100644 index 00000000..21d0b190 --- /dev/null +++ b/internal/article/testdata/redirect.golden @@ -0,0 +1,16 @@ +main -> +main/a/1 -> +Main -> /main +Main/ -> /main +Main/a/1/ -> /main/a/1 +Main/a/ -> /main/a/ +Main/b/2/a/1 -> /main/a/1/b/2 +Main/A/1/B/2 -> /main/a/1/b/2 +Main/norender/true/foo -> /main/norender/true/foo +Main/x/y%20z -> /main/x/y%20z +Main//x -> /main//x +Main/a/%2Fb -> /main/a/%2Fb +Main/%20x/1 -> /main/%20x/1 +%D1%80%D1%83%D1%81/a/1 -> /rus/a/1 +Some%20Page/a/1 -> /some-page/a/1 +SCP-173 -> /scp-173 diff --git a/internal/article/testdata/redirect_corpus.json b/internal/article/testdata/redirect_corpus.json new file mode 100644 index 00000000..704b7638 --- /dev/null +++ b/internal/article/testdata/redirect_corpus.json @@ -0,0 +1,18 @@ +[ + "main", + "main/a/1", + "Main", + "Main/", + "Main/a/1/", + "Main/a/", + "Main/b/2/a/1", + "Main/A/1/B/2", + "Main/norender/true/foo", + "Main/x/y%20z", + "Main//x", + "Main/a/%2Fb", + "Main/%20x/1", + "%D1%80%D1%83%D1%81/a/1", + "Some%20Page/a/1", + "SCP-173" +] diff --git a/internal/article/testdata/thispage.golden b/internal/article/testdata/thispage.golden new file mode 100644 index 00000000..6fd9a011 --- /dev/null +++ b/internal/article/testdata/thispage.golden @@ -0,0 +1,17 @@ +path|a -> "1" +path|A -> "1" +path|missing -> "%%path|missing%%" +path|norender -> "%%path|norender%%" +path| -> "%%path|%%" +path_expr|a -> "\"1\"" +path_expr|missing -> "\"%%path_expr|missing%%\"" +path_expr|norender -> "null" +path_expr|uni -> "\"\\u4e2d\"" +path_expr|q -> "\"x y/z\"" +path_url|q -> "x%20y%2Fz" +path_url|missing -> "%25%25path_url%7Cmissing%25%25" +path_url|norender -> "" +path_url|uni -> "%E4%B8%AD" +canonical_url -> "https://wiki.example/main/a/1" +PATH|a -> "%%PATH|a%%" +other -> "%%other%%" diff --git a/internal/article/testdata/thispage_corpus.json b/internal/article/testdata/thispage_corpus.json new file mode 100644 index 00000000..ba09a254 --- /dev/null +++ b/internal/article/testdata/thispage_corpus.json @@ -0,0 +1,40 @@ +{ + "canonical_url": "https://wiki.example/main/a/1", + "names": [ + "path|a", + "path|A", + "path|missing", + "path|norender", + "path|", + "path_expr|a", + "path_expr|missing", + "path_expr|norender", + "path_expr|uni", + "path_expr|q", + "path_url|q", + "path_url|missing", + "path_url|norender", + "path_url|uni", + "canonical_url", + "PATH|a", + "other" + ], + "params": [ + { + "key": "a", + "value": "1" + }, + { + "key": "norender", + "value": null + }, + { + "key": "q", + "value": "x y/z" + }, + { + "key": "uni", + "value": "中" + } + ] +} diff --git a/internal/article/unquote.go b/internal/article/unquote.go new file mode 100644 index 00000000..d938e4ab --- /dev/null +++ b/internal/article/unquote.go @@ -0,0 +1,101 @@ +package article + +import ( + "strings" + "unicode/utf8" +) + +// An escape unquote cannot read stays in the text, and bytes that are not UTF-8 +// turn into U+FFFD. +func unquote(s string) string { + if !strings.Contains(s, "%") { + return s + } + buf := make([]byte, 0, len(s)) + for i := 0; i < len(s); { + if s[i] == '%' && i+2 < len(s) { + if b, ok := hexByte(s[i+1], s[i+2]); ok { + buf = append(buf, b) + i += 3 + continue + } + } + buf = append(buf, s[i]) + i++ + } + return replaceInvalid(buf) +} + +func hexByte(hi, lo byte) (byte, bool) { + h, ok := hexDigit(hi) + if !ok { + return 0, false + } + l, ok := hexDigit(lo) + if !ok { + return 0, false + } + return h<<4 | l, true +} + +func hexDigit(c byte) (byte, bool) { + switch { + case c >= '0' && c <= '9': + return c - '0', true + case c >= 'a' && c <= 'f': + return c - 'a' + 10, true + case c >= 'A' && c <= 'F': + return c - 'A' + 10, true + } + return 0, false +} + +func replaceInvalid(b []byte) string { + if utf8.Valid(b) { + return string(b) + } + var out strings.Builder + for i := 0; i < len(b); { + if r, size := utf8.DecodeRune(b[i:]); r != utf8.RuneError || size > 1 { + out.Write(b[i : i+size]) + i += size + continue + } + out.WriteRune(utf8.RuneError) + i += subpartLen(b[i:]) + } + return out.String() +} + +// One U+FFFD stands for a whole maximal subpart, so a truncated sequence costs +// one replacement rather than one per byte. +func subpartLen(b []byte) int { + var want int + var lo, hi byte + switch c := b[0]; { + case c >= 0xc2 && c <= 0xdf: + want, lo, hi = 2, 0x80, 0xbf + case c == 0xe0: + want, lo, hi = 3, 0xa0, 0xbf + case c >= 0xe1 && c <= 0xec, c == 0xee, c == 0xef: + want, lo, hi = 3, 0x80, 0xbf + case c == 0xed: + want, lo, hi = 3, 0x80, 0x9f + case c == 0xf0: + want, lo, hi = 4, 0x90, 0xbf + case c >= 0xf1 && c <= 0xf3: + want, lo, hi = 4, 0x80, 0xbf + case c == 0xf4: + want, lo, hi = 4, 0x80, 0x8f + default: + return 1 + } + if len(b) < 2 || b[1] < lo || b[1] > hi { + return 1 + } + n := 2 + for n < want && n < len(b) && b[n] >= 0x80 && b[n] <= 0xbf { + n++ + } + return n +} diff --git a/internal/article/vars.go b/internal/article/vars.go new file mode 100644 index 00000000..646c241a --- /dev/null +++ b/internal/article/vars.go @@ -0,0 +1,59 @@ +package article + +import ( + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/wikijson" +) + +const ( + prefixPath = "path|" + prefixExpr = "path_expr|" + prefixURL = "path_url|" + varCanonic = "canonical_url" +) + +// ThisPage answers the substitutions only the request can answer. The name it +// is handed keeps its case while the key it looks up does not, so %%PATH|a%% +// resolves to nothing at all. +func ThisPage(params Params, canonicalURL string) func(string) (string, bool) { + return func(name string) (string, bool) { + switch { + case strings.HasPrefix(name, prefixExpr): + param, ok := params.Lookup(lookupKey(name, prefixExpr)) + switch { + case !ok: + return wikijson.String(literal(name)), true + case param.Bare: + return "null", true + } + return wikijson.String(param.Value), true + + case strings.HasPrefix(name, prefixURL): + param, ok := params.Lookup(lookupKey(name, prefixURL)) + if !ok { + return page.QuoteAll(literal(name)), true + } + // The empty string is what the rest of this family answers for a bare key. + return page.QuoteAll(param.Value), true + + case strings.HasPrefix(name, prefixPath): + param, ok := params.Lookup(lookupKey(name, prefixPath)) + if !ok || param.Bare { + return "", false + } + return param.Value, true + + case name == varCanonic: + return canonicalURL, true + } + return "", false + } +} + +func lookupKey(name, prefix string) string { + return strings.ToLower(strings.TrimPrefix(name, prefix)) +} + +func literal(name string) string { return "%%" + name + "%%" } diff --git a/internal/articlepage/articlepage.go b/internal/articlepage/articlepage.go new file mode 100644 index 00000000..aaaff2cf --- /dev/null +++ b/internal/articlepage/articlepage.go @@ -0,0 +1,108 @@ +// Package articlepage answers the article URLs, which is every path the wiki +// has not claimed for something else. +package articlepage + +import ( + "errors" + "log/slog" + "net/http" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/roles" + "github.com/WikitTeam/ProjectWikit/internal/shell" + "github.com/WikitTeam/ProjectWikit/internal/static" +) + +type Deps struct { + DB *db.DB + Engine renderer.Renderer + Bundle *i18n.Bundle + Icons roles.IconLoader + Assets *static.Assets + GoogleTagID string + Log *slog.Logger + + // Now exists so a test can pin the moment an inactive account comes back. + Now func() time.Time +} + +type Handler struct { + deps Deps + shell func(loc *i18n.Localizer) *shell.Renderer +} + +const allowedMethods = "GET, HEAD, OPTIONS" + +var _ http.Handler = (*Handler)(nil) + +func (h *Handler) log() *slog.Logger { + if h.deps.Log == nil { + return slog.Default() + } + return h.deps.Log +} + +func New(d Deps) *Handler { + if d.Now == nil { + d.Now = time.Now + } + return &Handler{ + deps: d, + shell: func(loc *i18n.Localizer) *shell.Renderer { + return shell.New(loc, d.Assets) + }, + } +} + +func (h *Handler) now() time.Time { return h.deps.Now() } + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Allow", allowedMethods) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", allowedMethods) + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + out, err := h.build(r) + if err != nil { + h.log().Error("render article", "path", r.URL.Path, "err", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if out.Location != "" { + w.Header().Set("Location", out.Location) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Content-Length", "0") + w.WriteHeader(out.Status) + return + } + + if out.SetCSRF != "" { + http.SetCookie(w, &http.Cookie{ + Name: csrf.CookieName, + Value: out.SetCSRF, + Path: "/", + MaxAge: csrf.CookieMaxAge, + SameSite: http.SameSiteLaxMode, + }) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(out.Status) + if r.Method == http.MethodHead { + return + } + if _, err := w.Write([]byte(out.Body)); err != nil && !errors.Is(err, http.ErrBodyNotAllowed) { + return + } +} diff --git a/internal/articlepage/articlepage_test.go b/internal/articlepage/articlepage_test.go new file mode 100644 index 00000000..b7095b70 --- /dev/null +++ b/internal/articlepage/articlepage_test.go @@ -0,0 +1,130 @@ +package articlepage + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/article" +) + +func TestExcerpt(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"one line", "one line"}, + {" padded ", "padded"}, + {"a\n\n\nb", "a\nb"}, + {" a \n \n b ", "a\nb"}, + {"a\nb\nc", "a\nb\nc"}, + } + for _, c := range cases { + if got := excerpt(c.in); got != c.want { + t.Errorf("excerpt(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestExcerptTruncatesByRunes(t *testing.T) { + got := excerpt(strings.Repeat("中", 500)) + want := strings.Repeat("中", excerptLimit) + "..." + if got != want { + t.Errorf("len([]rune(excerpt(500 runes))) = %d, want %d", len([]rune(got)), len([]rune(want))) + } +} + +func TestExcerptKeepsAnExactFit(t *testing.T) { + got := excerpt(strings.Repeat("a", excerptLimit)) + if strings.HasSuffix(got, "...") { + t.Errorf("excerpt(%d chars) ends with an ellipsis, want none", excerptLimit) + } +} + +func TestPathParamsWritesBareKeysAsNull(t *testing.T) { + _, params := article.ParsePath("main/offset/20/bare", "main") + got := pathParams(params) + if len(got) != 2 { + t.Fatalf("len(pathParams()) = %d, want 2", len(got)) + } + if got[0].Key != "offset" || got[0].Value != "20" { + t.Errorf("pathParams()[0] = %v, want offset=20", got[0]) + } + if got[1].Key != "bare" || got[1].Value != nil { + t.Errorf("pathParams()[1] = %v, want bare=nil", got[1]) + } +} + +func TestFirstNonEmpty(t *testing.T) { + if got := firstNonEmpty("", "second", "third"); got != "second" { + t.Errorf("firstNonEmpty() = %q, want %q", got, "second") + } + if got := firstNonEmpty("", ""); got != "" { + t.Errorf("firstNonEmpty() = %q, want %q", got, "") + } +} + +func TestPostIsNotAllowed(t *testing.T) { + rec := httptest.NewRecorder() + New(Deps{}).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/main", nil)) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /main = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } + if got := rec.Header().Get("Allow"); got != allowedMethods { + t.Errorf("Allow = %q, want %q", got, allowedMethods) + } +} + +func TestOptionsIsAnswered(t *testing.T) { + rec := httptest.NewRecorder() + New(Deps{}).ServeHTTP(rec, httptest.NewRequest(http.MethodOptions, "/main", nil)) + + if rec.Code != http.StatusOK { + t.Errorf("OPTIONS /main = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Header().Get("Allow"); got != allowedMethods { + t.Errorf("Allow = %q, want %q", got, allowedMethods) + } + if got := rec.Header().Get("Content-Length"); got != "0" { + t.Errorf("Content-Length = %q, want %q", got, "0") + } + if got := rec.Body.String(); got != "" { + t.Errorf("OPTIONS /main body = %q, want %q", got, "") + } +} + +func TestRequestWithoutASiteFails(t *testing.T) { + rec := httptest.NewRecorder() + New(Deps{}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/main", nil)) + + if rec.Code != http.StatusInternalServerError { + t.Errorf("GET /main without a site = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +func TestNotFoundNamesAskTheCategoryFirst(t *testing.T) { + got := notFoundNames("scp:9999") + want := []string{"scp:_404", "_404"} + if len(got) != len(want) { + t.Fatalf("notFoundNames(%q) = %v, want %v", "scp:9999", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("notFoundNames(%q)[%d] = %q, want %q", "scp:9999", i, got[i], want[i]) + } + } +} + +func TestNotFoundNamesOfTheDefaultCategory(t *testing.T) { + got := notFoundNames("no-such-page") + if len(got) != 1 || got[0] != "_404" { + t.Errorf("notFoundNames(%q) = %v, want [_404]", "no-such-page", got) + } +} + +func TestNotFoundNamesOfAnExplicitDefaultCategory(t *testing.T) { + got := notFoundNames("_default:no-such-page") + if len(got) != 1 || got[0] != "_404" { + t.Errorf("notFoundNames(%q) = %v, want [_404]", "_default:no-such-page", got) + } +} diff --git a/internal/articlepage/build.go b/internal/articlepage/build.go new file mode 100644 index 00000000..9bf508fe --- /dev/null +++ b/internal/articlepage/build.go @@ -0,0 +1,147 @@ +package articlepage + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/article" + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/repo" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/wikidot" +) + +type result struct { + Status int + Location string + Body string + + SetCSRF string +} + +type request struct { + ctx context.Context + loc *i18n.Localizer + site *db.Site + user *db.User + + name string + params article.Params + encoded string + + article *db.Article + forbidden bool + perms perms.Set + + csrf string + csrfNew bool +} + +func (h *Handler) build(r *http.Request) (*result, error) { + ctx := r.Context() + found := site.FromContext(ctx) + if found == nil { + return nil, errors.New("articlepage: the request carries no site") + } + + req := &request{ + ctx: ctx, + loc: h.deps.Bundle.For(ctx), + site: found, + user: auth.FromContext(ctx), + } + req.csrf, req.csrfNew = csrf.Token(r) + req.name, req.params = article.ParsePath(strings.TrimPrefix(r.URL.EscapedPath(), "/"), found.HomePage) + req.encoded = article.Encode(req.params) + + if target, ok := article.RedirectTarget(req.name, req.params); ok { + return &result{Status: http.StatusFound, Location: target}, nil + } + + if err := h.load(req); err != nil { + return nil, err + } + + if req.article != nil && req.params.Get("comments") == "show" { + target, err := h.commentsRedirect(req) + if err != nil { + return nil, err + } + if target != "" { + return &result{Status: http.StatusFound, Location: target}, nil + } + } + + return h.render(req) +} + +// A page the visitor may not see is dropped here, which is what leaves every +// layer below working on nothing at all. +func (h *Handler) load(req *request) error { + found, err := h.deps.DB.ArticleByName(req.ctx, req.site.ID, req.name) + if err != nil && !errors.Is(err, db.ErrNotFound) { + return err + } + req.article = found + + perm := repo.NewPerms(req.ctx, h.deps.DB) + subject, err := perm.Subject(req.user, h.now()) + if err != nil { + return err + } + + object, checked, err := h.permsObject(req, perm) + if err != nil { + return err + } + req.perms = perms.Resolve(subject, object) + if checked && !req.perms.Has(perms.ViewArticles) { + req.forbidden = true + req.article = nil + } + return nil +} + +func (h *Handler) permsObject(req *request, perm *repo.Perms) (*perms.Object, bool, error) { + if req.article != nil { + object, err := perm.Article(req.article, req.user) + return object, true, err + } + category, _ := wikidot.Split(req.name) + exists, err := h.deps.DB.CategoryExists(req.ctx, req.site.ID, category) + if err != nil { + return nil, false, err + } + if !exists { + return nil, false, nil + } + object, err := perm.Category(category) + return object, true, err +} + +// Asking for the discussion is what opens the thread, so the link always lands +// on one even for a page nobody has commented on. +func (h *Handler) commentsRedirect(req *request) (string, error) { + id, err := h.deps.DB.CommentThreadFor(req.ctx, req.site.ID, req.article.ID) + if err != nil { + return "", err + } + // The slug is the page name. Normalizing the title instead drops every + // character outside ASCII, which leaves a stranger's page unrecognisable. + return "/forum/t-" + strconv.FormatInt(id, 10) + "/" + req.article.FullName(), nil +} + +func (h *Handler) canonicalURL(req *request) string { + name := req.name + if req.article != nil { + name = req.article.FullName() + } + return "https://" + req.site.Domain + "/" + name + req.encoded +} diff --git a/internal/articlepage/render.go b/internal/articlepage/render.go new file mode 100644 index 00000000..e4d978a6 --- /dev/null +++ b/internal/articlepage/render.go @@ -0,0 +1,658 @@ +package articlepage + +import ( + "errors" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/article" + "github.com/WikitTeam/ProjectWikit/internal/callbacks" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/form" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/pageconfig" + "github.com/WikitTeam/ProjectWikit/internal/pagerender" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/repo" + "github.com/WikitTeam/ProjectWikit/internal/shell" + "github.com/WikitTeam/ProjectWikit/internal/site" + "github.com/WikitTeam/ProjectWikit/internal/timezone" + "github.com/WikitTeam/ProjectWikit/internal/wikidot" + "github.com/WikitTeam/ProjectWikit/internal/wikijson" +) + +const ( + templateName = "_template" + notFoundName = "_404" + excerptLimit = 384 +) + +type body struct { + html string + status int + excerpt string + image string + title string + rev int + updatedAt time.Time + style string + redirect string +} + +func (h *Handler) render(req *request) (*result, error) { + navTop, topStyle, err := h.nav(req, "nav:top") + if err != nil { + return nil, err + } + navSide, sideStyle, err := h.nav(req, "nav:side") + if err != nil { + return nil, err + } + + canonical := h.canonicalURL(req) + out, err := h.body(req, canonical) + if err != nil { + return nil, err + } + if out.redirect != "" { + return &result{Status: http.StatusFound, Location: out.redirect}, nil + } + out.style = topStyle + sideStyle + out.style + + data, err := h.shellData(req, out, canonical, navTop, navSide) + if err != nil { + return nil, err + } + + var page strings.Builder + if err := h.shell(req.loc).Page(&page, data); err != nil { + return nil, err + } + body := page.String() + res := &result{Status: out.status, Body: body} + // Only a page that actually put the token in a form gets the cookie, so a + // plain reader is never handed one they have no use for. + if req.csrfNew && strings.Contains(body, req.csrf) { + res.SetCSRF = req.csrf + } + return res, nil +} + +func (h *Handler) body(req *request, canonical string) (body, error) { + switch { + case req.forbidden: + html, err := h.shell(req.loc).Forbidden(req.name) + return body{html: html, status: http.StatusForbidden}, err + case req.article != nil: + return h.articleBody(req, canonical) + default: + return h.missingBody(req) + } +} + +func (h *Handler) articleBody(req *request, canonical string) (body, error) { + source, err := h.source(req) + if err != nil { + return body{}, err + } + vars := h.vars(req, req.article) + source = page.PageVars(source, vars, 1, 1) + source = page.ApplyTemplate(source, article.ThisPage(req.params, canonical)) + // The field definitions are read by the variables, not by the reader, so + // they never reach the renderer even on the template's own page. + source = form.Strip(source) + source = page.PreRender(source, vars) + + info, err := h.pageInfo(req, req.article) + if err != nil { + return body{}, err + } + + pc := h.context(req, req.article) + html, err := h.env(req).HTML(source, info, h.callbacks(req, vars, pc), renderer.ModeArticle) + if err != nil { + return body{}, err + } + + req.params = pc.PathParams + + textPC := h.context(req, req.article) + text, err := h.env(req).Text(source, info, h.callbacks(req, vars, textPC), renderer.ModeArticle) + if err != nil { + return body{}, err + } + req.params = textPC.PathParams + + rev, err := h.deps.DB.LatestRevNumber(req.ctx, req.article.ID) + if err != nil { + return body{}, err + } + out := body{ + html: html.Body, + status: pc.Status, + excerpt: excerpt(text.Body), + title: pc.Title, + rev: rev, + updatedAt: req.article.UpdatedAt, + style: pc.ComputedStyle, + redirect: pc.RedirectTo, + image: pc.OGImage, + } + if pc.OGDescription != "" { + out.excerpt = pc.OGDescription + } + return out, nil +} + +func (h *Handler) context(req *request, source *db.Article) *page.Context { + pc := page.NewContext(req.article, source, req.params, req.user) + return pc +} + +// A page named _template is its own content, which is what keeps the template +// from wrapping itself. +func (h *Handler) source(req *request) (string, error) { + if req.article.Name == templateName { + return "%%content%%", nil + } + found, err := h.deps.DB.ArticleByName(req.ctx, req.site.ID, req.article.Category+":"+templateName) + if errors.Is(err, db.ErrNotFound) { + return "%%content%%", nil + } + if err != nil { + return "", err + } + source, err := h.deps.DB.LatestSource(req.ctx, found.ID) + if errors.Is(err, db.ErrNotFound) { + return "%%content%%", nil + } + if err != nil { + return "", err + } + return source, nil +} + +func (h *Handler) missingBody(req *request) (body, error) { + options, err := wikijson.Marshal(wikijson.Object{ + {Key: "page_id", Value: req.name}, + {Key: "pathParams", Value: pathParams(req.params)}, + }) + if err != nil { + return body{}, err + } + category, _ := wikidot.Split(req.name) + object, err := repo.NewPerms(req.ctx, h.deps.DB).Category(category) + if err != nil { + return body{}, err + } + subject, err := h.subject(req) + if err != nil { + return body{}, err + } + allow := wikidot.NameAllowed(req.name) && perms.Resolve(subject, object).Has(perms.CreateArticles) + + written, err := h.notFoundPage(req) + if err != nil { + return body{}, err + } + if written != nil { + return *written, nil + } + + // The name is left out because the view never puts it in this template's + // context, so the message it renders has an empty slot where it goes. + html, err := h.shell(req.loc).NotFound(shell.NotFound{ + AllowCreate: allow, + Options: options, + }) + return body{html: html, status: http.StatusNotFound}, err +} + +func (h *Handler) notFoundPage(req *request) (*body, error) { + perm := repo.NewPerms(req.ctx, h.deps.DB) + subject, err := h.subject(req) + if err != nil { + return nil, err + } + + for _, name := range notFoundNames(req.name) { + found, err := h.deps.DB.ArticleByName(req.ctx, req.site.ID, name) + if errors.Is(err, db.ErrNotFound) { + continue + } + if err != nil { + return nil, err + } + // A template the reader may not read is not a template for them, and + // showing it anyway would hand out a page they were refused. + object, err := perm.Article(found, req.user) + if err != nil { + return nil, err + } + if !perms.Resolve(subject, object).Has(perms.ViewArticles) { + continue + } + source, err := h.deps.DB.LatestSource(req.ctx, found.ID) + if errors.Is(err, db.ErrNotFound) { + continue + } + if err != nil { + return nil, err + } + out, err := h.renderNotFound(req, found, source) + return &out, err + } + return nil, nil +} + +// The category's own page comes first so a category can answer differently. A +// page in the default category would ask for the same row twice. +func notFoundNames(fullName string) []string { + category, _ := wikidot.Split(fullName) + if category == wikidot.DefaultCategory { + return []string{notFoundName} + } + return []string{category + ":" + notFoundName, notFoundName} +} + +// The page the reader asked for has no row, so it is stood up from the name +// alone. What the template names has to be that page, not the template. +func (h *Handler) renderNotFound(req *request, template *db.Article, source string) (body, error) { + category, name := wikidot.Split(req.name) + asked := &db.Article{Category: category, Name: name} + + // The variables answer for the page that was asked for rather than for the + // template, so a template can name what is missing. + vars := h.vars(req, asked) + source = page.PageVars(source, vars, 1, 1) + source = page.ApplyTemplate(source, missingName(req.name)) + source = page.PreRender(source, vars) + info, err := h.pageInfo(req, template) + if err != nil { + return body{}, err + } + + pc := page.NewContext(asked, template, req.params, req.user) + html, err := h.env(req).HTML(source, info, h.callbacks(req, vars, pc), renderer.ModeArticle) + if err != nil { + return body{}, err + } + req.params = pc.PathParams + + status := http.StatusNotFound + if pc.Status != 0 && pc.Status != http.StatusOK { + status = pc.Status + } + return body{ + html: html.Body, + status: status, + title: pc.Title, + style: pc.ComputedStyle, + redirect: pc.RedirectTo, + }, nil +} + +// Answered on this pass and nowhere else, so a page that exists leaves the +// variable standing instead of naming itself. +func missingName(fullName string) func(string) (string, bool) { + return func(name string) (string, bool) { + if name == "404_page_name" { + return fullName, true + } + return "", false + } +} + +// nav renders one of the two navigation pages. It gets its own callbacks and +// its own PageInfo, since the page it decorates is a different row. +func (h *Handler) nav(req *request, name string) (string, string, error) { + found, err := h.deps.DB.ArticleByName(req.ctx, req.site.ID, name) + if errors.Is(err, db.ErrNotFound) { + return "", "", nil + } + if err != nil { + return "", "", err + } + source, err := h.deps.DB.LatestSource(req.ctx, found.ID) + if errors.Is(err, db.ErrNotFound) { + return "", "", nil + } + if err != nil { + return "", "", err + } + + vars := h.vars(req, req.article) + info, err := h.pageInfo(req, found) + if err != nil { + return "", "", err + } + pc := h.context(req, found) + html, err := h.env(req).HTML(page.PreRender(source, vars), info, h.callbacks(req, vars, pc), renderer.ModeArticle) + if err != nil { + return "", "", err + } + return html.Body, pc.ComputedStyle, nil +} + +func (h *Handler) env(req *request) *pagerender.Env { + return pagerender.Deps{DB: h.deps.DB, Engine: h.deps.Engine, Icons: h.deps.Icons}. + Env(req.ctx, req.loc, req.site, req.user) +} + +func (h *Handler) callbacks(req *request, vars *page.Vars, pc *page.Context) *callbacks.Callbacks { + return h.env(req).Callbacks(vars, pc) +} + +func (h *Handler) license(req *request) (string, error) { + source := strings.TrimSpace(req.site.FooterLicense) + if source == "" { + return "", nil + } + pc := page.NewContext(nil, nil, nil, req.user) + // This text is site configuration rather than an article, so it does not get + // to reach the whole module set. + pc.OnlyModules = []string{"time"} + info, err := h.pageInfo(req, nil) + if err != nil { + return "", err + } + html, err := h.env(req).HTML(source, info, h.callbacks(req, nil, pc), renderer.ModeSystemWithModules) + if err != nil { + return "", err + } + // The licence area holds a single line, so the paragraphs come back off. + return unwrapParagraphs(html.Body), nil +} + +func unwrapParagraphs(html string) string { + out := strings.ReplaceAll(html, "

      ", "") + out = strings.ReplaceAll(out, "

      ", "") + return strings.TrimSpace(out) +} + +func (h *Handler) vars(req *request, of *db.Article) *page.Vars { + return h.env(req).Vars(of) +} + +func (h *Handler) pageInfo(req *request, source *db.Article) (renderer.PageInfo, error) { + return h.env(req).PageInfo(source) +} + +func (h *Handler) subject(req *request) (perms.Subject, error) { + return repo.NewPerms(req.ctx, h.deps.DB).Subject(req.user, h.now()) +} + +var newlines = regexp.MustCompile(`\n+`) + +// excerpt trims every line and collapses the blank ones, which is the shape +// og:description carries. +func excerpt(text string) string { + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(line) + } + joined := newlines.ReplaceAllString(strings.Join(lines, "\n"), "\n") + runes := []rune(joined) + if len(runes) > excerptLimit { + return string(runes[:excerptLimit]) + "..." + } + return joined +} + +func pathParams(params article.Params) wikijson.Object { + out := make(wikijson.Object, 0, len(params)) + for _, param := range params { + var value any + if !param.Bare { + value = param.Value + } + out = append(out, wikijson.Field{Key: param.Key, Value: value}) + } + return out +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func (h *Handler) shellData(req *request, out body, canonical, navTop, navSide string) (shell.Data, error) { + theme, err := h.themeURL(req) + if err != nil { + return shell.Data{}, err + } + indexed, err := h.indexed(req) + if err != nil { + return shell.Data{}, err + } + crumbs, err := h.breadcrumbs(req) + if err != nil { + return shell.Data{}, err + } + tags, err := h.tagBlock(req) + if err != nil { + return shell.Data{}, err + } + login, err := h.loginStatus(req) + if err != nil { + return shell.Data{}, err + } + options, err := h.options(req) + if err != nil { + return shell.Data{}, err + } + + license, err := h.license(req) + if err != nil { + return shell.Data{}, err + } + + title := firstNonEmpty(out.title, req.site.Title) + // The site name belongs in the browser tab, not in a share card headline. + document := title + if title != req.site.Title && req.site.Title != "" { + document = title + " - " + req.site.Title + } + return shell.Data{ + SiteName: req.site.Title, + SiteHeadline: req.site.Headline, + SiteTitle: document, + SiteIcon: req.site.Icon, + License: license, + OGTitle: title, + OGDescription: out.excerpt, + OGImage: out.image, + OGURL: canonical, + NoIndex: !indexed, + GoogleTagID: h.deps.GoogleTagID, + ThemeURL: theme, + ComputedStyle: out.style, + NavTop: navTop, + NavSide: navSide, + Title: out.title, + Content: out.html, + Breadcrumbs: crumbs, + TagCategories: tags, + RevNumber: out.rev, + UpdatedAt: out.updatedAt, + TimeZone: timezone.Load(req.site.TimeZone), + LoginStatusConfig: login, + OptionsConfig: options, + }, nil +} + +func (h *Handler) themeURL(req *request) (string, error) { + return site.ThemeURLByID(req.ctx, h.deps.DB, req.site.ThemeID) +} + +func (h *Handler) indexed(req *request) (bool, error) { + category, _ := wikidot.Split(req.name) + return h.deps.DB.CategoryIndexed(req.ctx, req.site.ID, category) +} + +func (h *Handler) breadcrumbs(req *request) ([]shell.Breadcrumb, error) { + if req.article == nil { + return nil, nil + } + chain, err := h.deps.DB.Breadcrumbs(req.ctx, req.article.ID) + if err != nil { + return nil, err + } + out := make([]shell.Breadcrumb, 0, len(chain)) + for i := range chain { + out = append(out, shell.Breadcrumb{URL: "/" + chain[i].FullName(), Title: chain[i].Title}) + } + return out, nil +} + +func (h *Handler) tagBlock(req *request) ([]shell.TagCategory, error) { + if req.article == nil { + return nil, nil + } + categories, err := h.deps.DB.ArticleTagCategories(req.ctx, req.article.ID) + if err != nil { + return nil, err + } + out := make([]shell.TagCategory, 0, len(categories)) + for _, category := range categories { + one := shell.TagCategory{Name: category.Name} + for _, tag := range category.Tags { + one.Tags = append(one.Tags, shell.Tag{Name: tag.Name, FullName: tag.FullName}) + } + out = append(out, one) + } + return out, nil +} + +func (h *Handler) loginStatus(req *request) (string, error) { + status := pageconfig.LoginStatus{User: req.user} + if req.user != nil { + userRoles, err := h.deps.DB.RolesByUser(req.ctx, req.site.ID, req.user.ID) + if err != nil { + return "", err + } + count, err := h.deps.DB.UnreadNotifications(req.ctx, req.user.ID) + if err != nil { + return "", err + } + subject, err := h.subject(req) + if err != nil { + return "", err + } + status.Roles = userRoles + status.NotificationCount = count + status.CanEditArticles = perms.Resolve(subject, nil).Has(perms.EditArticles) + } + return status.JSON(req.loc) +} + +func (h *Handler) options(req *request) (string, error) { + options := pageconfig.Options{ + PageID: req.name, + NormalizedName: req.name, + HasArticle: req.article != nil, + Anonymous: req.user == nil, + Perms: req.perms, + PathParams: req.params, + Rating: page.DisabledRating(), + } + + tags, err := h.deps.DB.SiteCanCreateTags(req.ctx, req.site.ID) + if err != nil && !errors.Is(err, db.ErrNotFound) { + return "", err + } + options.CanCreateTags = tags == db.CreateTagsEnabled + + if req.user != nil { + raw, err := h.deps.DB.UserPreference(req.ctx, req.user.ID, + pageconfig.PreferenceSection, pageconfig.PreferenceAdvancedSourceEditor) + if err != nil && !errors.Is(err, db.ErrNotFound) { + return "", err + } + options.Preferences.AdvancedSourceEditor = pageconfig.PreferenceEnabled(raw) + } + + if req.article != nil { + rating, err := h.rating(req) + if err != nil { + return "", err + } + options.Rating = rating + + info, err := h.deps.DB.CommentInfo(req.ctx, req.article.ID) + if err != nil { + return "", err + } + options.CommentCount = info.Count + options.CommentThreadID = info.ThreadID + options.CommentSlug = req.article.FullName() + + watching, err := h.watching(req, info.ThreadID) + if err != nil { + return "", err + } + options.IsWatching = watching + + favourites, err := h.deps.DB.ArticleFavouriteCount(req.ctx, req.article.ID) + if err != nil { + return "", err + } + options.Favourites = favourites + if req.user != nil { + mine, err := h.deps.DB.HasFavourited(req.ctx, req.article.ID, req.user.ID) + if err != nil { + return "", err + } + options.IsFavourited = mine + } + } + return options.JSON() +} + +func (h *Handler) rating(req *request) (page.Rating, error) { + siteMode, err := h.deps.DB.SiteRatingMode(req.ctx, req.site.ID) + if err != nil && !errors.Is(err, db.ErrNotFound) { + return page.Rating{}, err + } + categoryMode, err := h.deps.DB.CategoryRatingMode(req.ctx, req.site.ID, req.article.Category) + if err != nil && !errors.Is(err, db.ErrNotFound) { + return page.Rating{}, err + } + mode := page.RatingMode(siteMode, categoryMode) + if mode == page.RatingModeDisabled { + return page.RatingOf(mode, db.VoteStats{}), nil + } + stats, err := h.deps.DB.VoteStats(req.ctx, req.article.ID) + if err != nil { + return page.Rating{}, err + } + return page.RatingOf(mode, stats), nil +} + +// watching asks about the page and about the thread the path names. A t that is +// not a number leaves the second question unasked. +func (h *Handler) watching(req *request, threadID int64) (bool, error) { + if req.user == nil { + return false, nil + } + onArticle, err := h.deps.DB.SubscribedToArticle(req.ctx, req.user.ID, req.article.ID) + if err != nil { + return false, err + } + if onArticle { + return true, nil + } + fromPath, err := strconv.ParseInt(req.params.Get("t"), 10, 64) + if err != nil { + return false, nil + } + return h.deps.DB.SubscribedToThread(req.ctx, req.user.ID, fromPath) +} diff --git a/internal/articlepage/render_test.go b/internal/articlepage/render_test.go new file mode 100644 index 00000000..eed1aaf7 --- /dev/null +++ b/internal/articlepage/render_test.go @@ -0,0 +1,39 @@ +package articlepage + +import "testing" + +func TestUnwrapParagraphs(t *testing.T) { + cases := []struct{ in, want string }{ + {"

      one line

      ", "one line"}, + {"

      a

      \nb\n

      c

      ", "a\nb\nc"}, + {"", ""}, + {"no markup", "no markup"}, + } + for _, c := range cases { + if got := unwrapParagraphs(c.in); got != c.want { + t.Errorf("unwrapParagraphs(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestMissingNameResolves404PageName(t *testing.T) { + resolve := missingName("component:no-such-page") + + got, ok := resolve("404_page_name") + if !ok { + t.Fatal("missingName(...)(\"404_page_name\") = _, false, want true") + } + if want := "component:no-such-page"; got != want { + t.Errorf("missingName(...)(\"404_page_name\") = %q, want %q", got, want) + } +} + +func TestMissingNameLeavesOtherNames(t *testing.T) { + resolve := missingName("no-such-page") + + for _, name := range []string{"fullname", "404_page_name ", "404_PAGE_NAME"} { + if _, ok := resolve(name); ok { + t.Errorf("missingName(...)(%q) = _, true, want false", name) + } + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..d30433be --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,133 @@ +// Package auth resolves the signed-in user from the session cookie. +package auth + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/session" +) + +type Sessions interface { + SessionByKey(ctx context.Context, key string) (string, time.Time, error) + DeleteSession(ctx context.Context, key string) error +} + +type Users interface { + UserForSession(ctx context.Context, id int64) (*db.User, string, error) + BotByAPIKey(ctx context.Context, key string) (*db.User, error) +} + +type Resolver struct { + store *session.Store + sessions Sessions + users Users + log *slog.Logger +} + +func NewResolver(store *session.Store, sessions Sessions, users Users, log *slog.Logger) *Resolver { + return &Resolver{store: store, sessions: sessions, users: users, log: log} +} + +type contextKey struct{} + +// Middleware puts the signed-in user on the request context. Anonymous is not +// an error and not a separate branch: FromContext returns nil for it. +func (r *Resolver) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + if bot := r.bearer(req); bot != nil { + ctx = csrf.Exempt(ctx) + next.ServeHTTP(w, req.WithContext(NewContext(ctx, bot))) + return + } + user := r.resolve(req) + next.ServeHTTP(w, req.WithContext(NewContext(ctx, user))) + }) +} + +const bearerPrefix = "Bearer " + +func (r *Resolver) bearer(req *http.Request) *db.User { + key, ok := strings.CutPrefix(req.Header.Get("Authorization"), bearerPrefix) + if !ok || key == "" { + return nil + } + bot, err := r.users.BotByAPIKey(req.Context(), key) + if err != nil { + if !errors.Is(err, db.ErrNotFound) { + r.log.Error("read bot key", "err", err) + } + return nil + } + if !bot.ActiveAt(time.Now()) { + return nil + } + return bot +} + +func NewContext(ctx context.Context, user *db.User) context.Context { + return context.WithValue(ctx, contextKey{}, user) +} + +// FromContext returns nil when nobody is signed in. +func FromContext(ctx context.Context) *db.User { + user, _ := ctx.Value(contextKey{}).(*db.User) + return user +} + +func (r *Resolver) resolve(req *http.Request) *db.User { + cookie, err := req.Cookie(session.CookieName) + if err != nil || cookie.Value == "" { + return nil + } + ctx := req.Context() + + data, _, err := r.sessions.SessionByKey(ctx, cookie.Value) + if err != nil { + if !errors.Is(err, db.ErrNotFound) { + r.log.Error("read session", "err", err) + } + return nil + } + decoded, err := r.store.Decode(data) + if err != nil { + return nil + } + + raw, ok := session.UserID(decoded) + if !ok { + return nil + } + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil + } + + user, password, err := r.users.UserForSession(ctx, id) + if err != nil { + if !errors.Is(err, db.ErrNotFound) { + r.log.Error("read session user", "id", id, "err", err) + } + return nil + } + if !user.ActiveAt(time.Now()) { + return nil + } + + hash, _ := decoded[session.AuthUserHash].(string) + if !r.store.AuthHashMatches(password, hash) { + if err := r.sessions.DeleteSession(ctx, cookie.Value); err != nil { + r.log.Error("drop stale session", "err", err) + } + return nil + } + return user +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 00000000..fc9c5ece --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,299 @@ +package auth + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/csrf" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/session" +) + +const ( + secret = "test-secret-key" + password = "pbkdf2_sha256$1000000$abc$def=" + userID = 42 +) + +type fakeSessions struct { + data map[string]string + err error + deleted []string +} + +func (f *fakeSessions) SessionByKey(_ context.Context, key string) (string, time.Time, error) { + if f.err != nil { + return "", time.Time{}, f.err + } + data, ok := f.data[key] + if !ok { + return "", time.Time{}, db.ErrNotFound + } + return data, time.Now().Add(time.Hour), nil +} + +func (f *fakeSessions) DeleteSession(_ context.Context, key string) error { + f.deleted = append(f.deleted, key) + return nil +} + +type fakeUsers struct { + user *db.User + password string + err error + + bot *db.User + botKey string +} + +func (f *fakeUsers) BotByAPIKey(_ context.Context, key string) (*db.User, error) { + if f.bot == nil || key != f.botKey { + return nil, db.ErrNotFound + } + return f.bot, nil +} + +func (f *fakeUsers) UserForSession(_ context.Context, _ int64) (*db.User, string, error) { + if f.err != nil { + return nil, "", f.err + } + return f.user, f.password, nil +} + +func activeUser() *db.User { + return &db.User{ID: userID, Type: db.UserTypeNormal, Username: "seeduser", IsActive: true} +} + +func quietLog() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func signedSession(t *testing.T, store *session.Store, hash string) string { + t.Helper() + data, err := store.Encode(map[string]any{ + session.AuthUserID: "42", + session.AuthUserBackend: "django.contrib.auth.backends.ModelBackend", + session.AuthUserHash: hash, + }) + if err != nil { + t.Fatalf("Encode(...) err = %v, want nil", err) + } + return data +} + +type fixture struct { + resolver *Resolver + sessions *fakeSessions + users *fakeUsers + store *session.Store +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + store := session.New(secret) + sessions := &fakeSessions{data: map[string]string{}} + users := &fakeUsers{user: activeUser(), password: password} + return &fixture{ + resolver: NewResolver(store, sessions, users, quietLog()), + sessions: sessions, + users: users, + store: store, + } +} + +func (f *fixture) request(t *testing.T, key string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/main", nil) + if key != "" { + r.AddCookie(&http.Cookie{Name: session.CookieName, Value: key}) + } + return r +} + +func (f *fixture) resolveWith(t *testing.T, key, hash string) *db.User { + t.Helper() + f.sessions.data[key] = signedSession(t, f.store, hash) + return f.resolver.resolve(f.request(t, key)) +} + +func TestResolveSignedInUser(t *testing.T) { + f := newFixture(t) + got := f.resolveWith(t, "abc", f.store.AuthHash(password)) + + if got == nil { + t.Fatal("resolve(...) = nil, want the session's user") + } + if got.ID != userID { + t.Errorf("resolve(...).ID = %d, want %d", got.ID, userID) + } +} + +func TestResolveNoCookie(t *testing.T) { + f := newFixture(t) + if got := f.resolver.resolve(f.request(t, "")); got != nil { + t.Errorf("resolve(no cookie) = %v, want nil", got) + } +} + +func TestResolveUnknownSessionKey(t *testing.T) { + f := newFixture(t) + if got := f.resolver.resolve(f.request(t, "missing")); got != nil { + t.Errorf("resolve(unknown key) = %v, want nil", got) + } +} + +func TestResolveRejectsForgedSessionData(t *testing.T) { + f := newFixture(t) + f.sessions.data["abc"] = signedSession(t, session.New("wrong-secret"), f.store.AuthHash(password)) + + if got := f.resolver.resolve(f.request(t, "abc")); got != nil { + t.Errorf("resolve(session signed with another secret) = %v, want nil", got) + } +} + +func TestResolveRejectsStalePasswordHash(t *testing.T) { + f := newFixture(t) + got := f.resolveWith(t, "abc", f.store.AuthHash("pbkdf2_sha256$1000000$abc$old=")) + + if got != nil { + t.Errorf("resolve(session from before a password change) = %v, want nil", got) + } + if len(f.sessions.deleted) != 1 || f.sessions.deleted[0] != "abc" { + t.Errorf("deleted sessions = %v, want [abc]", f.sessions.deleted) + } +} + +func TestResolveRejectsMissingPasswordHash(t *testing.T) { + f := newFixture(t) + if got := f.resolveWith(t, "abc", ""); got != nil { + t.Errorf("resolve(session with no auth hash) = %v, want nil", got) + } +} + +func TestResolveAcceptsFallbackSecret(t *testing.T) { + rotated := session.New("new-secret", secret) + f := newFixture(t) + f.resolver = NewResolver(rotated, f.sessions, f.users, quietLog()) + f.sessions.data["abc"] = signedSession(t, session.New(secret), session.New(secret).AuthHash(password)) + + if got := f.resolver.resolve(f.request(t, "abc")); got == nil { + t.Error("resolve(session from before a key rotation) = nil, want the session's user") + } +} + +func TestResolveRejectsInactiveUser(t *testing.T) { + f := newFixture(t) + f.users.user.IsActive = false + + if got := f.resolveWith(t, "abc", f.store.AuthHash(password)); got != nil { + t.Errorf("resolve(inactive user) = %v, want nil", got) + } +} + +func TestResolveRejectsUserBannedUntilAFutureDate(t *testing.T) { + f := newFixture(t) + until := time.Now().Add(time.Hour) + f.users.user.InactiveUntil = &until + + if got := f.resolveWith(t, "abc", f.store.AuthHash(password)); got != nil { + t.Errorf("resolve(user banned until a future date) = %v, want nil", got) + } +} + +func TestResolveDeletedUser(t *testing.T) { + f := newFixture(t) + f.users.err = db.ErrNotFound + + if got := f.resolveWith(t, "abc", f.store.AuthHash(password)); got != nil { + t.Errorf("resolve(deleted user) = %v, want nil", got) + } +} + +func TestResolveDatabaseErrorIsAnonymous(t *testing.T) { + f := newFixture(t) + f.sessions.err = errors.New("connection refused") + + if got := f.resolver.resolve(f.request(t, "abc")); got != nil { + t.Errorf("resolve(with a failing database) = %v, want nil", got) + } +} + +func TestMiddlewarePutsUserOnTheContext(t *testing.T) { + f := newFixture(t) + f.sessions.data["abc"] = signedSession(t, f.store, f.store.AuthHash(password)) + + var seen *db.User + h := f.resolver.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + seen = FromContext(r.Context()) + })) + h.ServeHTTP(httptest.NewRecorder(), f.request(t, "abc")) + + if seen == nil { + t.Fatal("FromContext(...) = nil, want the session's user") + } + if seen.Username != "seeduser" { + t.Errorf("FromContext(...).Username = %q, want %q", seen.Username, "seeduser") + } +} + +func TestFromContextWithoutMiddleware(t *testing.T) { + if got := FromContext(context.Background()); got != nil { + t.Errorf("FromContext(bare context) = %v, want nil", got) + } +} + +func TestBearerTokenSignsInABot(t *testing.T) { + users := &fakeUsers{ + bot: &db.User{ID: 9, Type: db.UserTypeBot, Username: "probe-bot", IsActive: true}, + botKey: "probe-key", + } + resolver := NewResolver(session.New("secret"), &fakeSessions{err: db.ErrNotFound}, users, quietLog()) + + var got *db.User + var exempt bool + handler := resolver.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = FromContext(r.Context()) + exempt = csrf.Verify(r, []string{"wiki.example"}) == nil + })) + + req := httptest.NewRequest(http.MethodPost, "/pw-api/articles/new", nil) + req.Header.Set("Authorization", "Bearer probe-key") + handler.ServeHTTP(httptest.NewRecorder(), req) + + if got == nil { + t.Fatalf("FromContext() = nil, want the bot") + } + if got.Username != "probe-bot" { + t.Errorf("FromContext().Username = %q, want %q", got.Username, "probe-bot") + } + if !exempt { + t.Errorf("csrf.Verify() = error, want nil") + } +} + +func TestBearerTokenRefusesAnUnknownKey(t *testing.T) { + users := &fakeUsers{ + bot: &db.User{ID: 9, Type: db.UserTypeBot, Username: "probe-bot", IsActive: true}, + botKey: "probe-key", + } + resolver := NewResolver(session.New("secret"), &fakeSessions{err: db.ErrNotFound}, users, quietLog()) + + var got *db.User + handler := resolver.Middleware(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = FromContext(r.Context()) + })) + + req := httptest.NewRequest(http.MethodPost, "/pw-api/articles/new", nil) + req.Header.Set("Authorization", "Bearer wrong-key") + handler.ServeHTTP(httptest.NewRecorder(), req) + + if got != nil { + t.Errorf("FromContext() = %v, want nil", got) + } +} diff --git a/internal/auth/live_test.go b/internal/auth/live_test.go new file mode 100644 index 00000000..737588a1 --- /dev/null +++ b/internal/auth/live_test.go @@ -0,0 +1,68 @@ +package auth + +import ( + "context" + "os" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/session" +) + +const ( + envSecretKey = "PWIKIT_TEST_SECRET_KEY" + envSessionKey = "PWIKIT_TEST_SESSION_KEY" + envSessionUse = "PWIKIT_TEST_SESSION_USER" +) + +func TestResolveSessionWrittenByDjango(t *testing.T) { + dsn := os.Getenv(db.EnvDSN) + secretKey := os.Getenv(envSecretKey) + sessionKey := os.Getenv(envSessionKey) + wantUser := os.Getenv(envSessionUse) + if dsn == "" || secretKey == "" || sessionKey == "" || wantUser == "" { + t.Skipf("set %s, %s, %s and %s to run this", db.EnvDSN, envSecretKey, envSessionKey, envSessionUse) + } + + ctx := context.Background() + conn, err := db.Open(ctx, dsn) + if err != nil { + t.Fatalf("Open(dsn) err = %v, want nil", err) + } + t.Cleanup(conn.Close) + + f := newFixture(t) + f.resolver = NewResolver(session.New(secretKey), conn, conn, quietLog()) + + got := f.resolver.resolve(f.request(t, sessionKey)) + if got == nil { + t.Fatalf("resolve(%s) = nil, want %q", envSessionKey, wantUser) + } + if got.Username != wantUser { + t.Errorf("resolve(%s).Username = %q, want %q", envSessionKey, got.Username, wantUser) + } +} + +func TestResolveRejectsATamperedLiveSessionKey(t *testing.T) { + dsn := os.Getenv(db.EnvDSN) + secretKey := os.Getenv(envSecretKey) + sessionKey := os.Getenv(envSessionKey) + if dsn == "" || secretKey == "" || sessionKey == "" { + t.Skipf("set %s, %s and %s to run this", db.EnvDSN, envSecretKey, envSessionKey) + } + + ctx := context.Background() + conn, err := db.Open(ctx, dsn) + if err != nil { + t.Fatalf("Open(dsn) err = %v, want nil", err) + } + t.Cleanup(conn.Close) + + f := newFixture(t) + f.resolver = NewResolver(session.New(secretKey), conn, conn, quietLog()) + + tampered := "z" + sessionKey[1:] + if got := f.resolver.resolve(f.request(t, tampered)); got != nil { + t.Errorf("resolve(altered session key) = %v, want nil", got) + } +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 00000000..ad113a3f --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,915 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/migrate" +) + +const envDSN = "PWIKIT_TEST_WRITE_DSN" + +func requireDSN(t *testing.T) string { + t.Helper() + dsn := os.Getenv(envDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the backup test", envDSN) + } + return dsn +} + +func swapDatabase(t *testing.T, dsn, name string) string { + t.Helper() + cut := strings.LastIndex(dsn, "/") + if cut < 0 { + t.Fatalf("no database in %q, want a URL style connection string", dsn) + } + rest := "" + if q := strings.Index(dsn[cut:], "?"); q >= 0 { + rest = dsn[cut+q:] + } + return dsn[:cut+1] + name + rest +} + +func scratch(t *testing.T) string { + t.Helper() + dsn := requireDSN(t) + name := fmt.Sprintf("pwikit_backup_%d", rand.Uint32()) + admin := swapDatabase(t, dsn, "postgres") + + ctx := context.Background() + control, err := pgx.Connect(ctx, admin) + if err != nil { + t.Skipf("cannot reach the maintenance database to make a scratch one: %v", err) + } + defer control.Close(ctx) + + if _, err := control.Exec(ctx, `CREATE DATABASE `+pgx.Identifier{name}.Sanitize()); err != nil { + t.Fatalf("CREATE DATABASE err = %v, want nil", err) + } + t.Cleanup(func() { + clean, err := pgx.Connect(context.Background(), admin) + if err != nil { + return + } + defer clean.Close(context.Background()) + clean.Exec(context.Background(), + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1`, name) + clean.Exec(context.Background(), `DROP DATABASE IF EXISTS `+pgx.Identifier{name}.Sanitize()) + }) + return swapDatabase(t, dsn, name) +} + +func connect(t *testing.T, dsn string) *pgx.Conn { + t.Helper() + conn, err := pgx.Connect(context.Background(), dsn) + if err != nil { + t.Fatalf("Connect() err = %v, want nil", err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn +} + +func filled(t *testing.T) (dsn string, files string) { + t.Helper() + dsn = scratch(t) + if _, err := migrate.Run(context.Background(), dsn); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("Connect() err = %v, want nil", err) + } + defer conn.Close(ctx) + + _, err = conn.Exec(ctx, ` +INSERT INTO web_site (slug, title, headline, domain, media_domain, home_page, + footer_license, signup_notice, password_help, email_policy, membership_password, + membership_password_enabled, language) +VALUES ('probe', 'Probe', 'p', 'probe.test', 'media.probe.test', 'main', '', '', '', 'optional', '', false, 'zh-hans')`) + if err != nil { + t.Fatalf("insert site err = %v, want nil", err) + } + _, err = conn.Exec(ctx, ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, type, bio, is_forum_active, is_active, can_send_direct_messages, + pending_email, previous_email, language) +VALUES ('!', false, '', '', '', now(), 'probe-backup', 'normal', '', true, true, true, '', '', '')`) + if err != nil { + t.Fatalf("insert user err = %v, want nil", err) + } + + files = t.TempDir() + if err := os.MkdirAll(filepath.Join(files, "media", "deep"), 0o755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{ + "one.txt": "first", + "media/deep/two.bin": "\x00\x01\x02binary", + "media/deep/three.txt": strings.Repeat("x", 5000), + } { + if err := os.WriteFile(filepath.Join(files, filepath.FromSlash(name)), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return dsn, files +} + +func create(t *testing.T, dsn, files string) (string, Manifest) { + t.Helper() + out := filepath.Join(t.TempDir(), "probe"+Extension) + result, err := Create(context.Background(), CreateOptions{DSN: dsn, Files: files, Output: out}) + if err != nil { + t.Fatalf("Create() err = %v, want nil", err) + } + return result.Path, result.Manifest +} + +func TestCreateThenVerifyThenRestore(t *testing.T) { + source, files := filled(t) + name, made := create(t, source, files) + + if made.Files.Count != 3 { + t.Errorf("Create().Files.Count = %d, want 3", made.Files.Count) + } + if made.Tables["web_site"].Rows != 1 { + t.Errorf("Create().Tables[web_site].Rows = %d, want 1", made.Tables["web_site"].Rows) + } + if len(made.Migrations) != len(migrate.Names()) { + t.Errorf("len(Create().Migrations) = %d, want %d", len(made.Migrations), len(migrate.Names())) + } + + report, err := Verify(name) + if err != nil { + t.Fatalf("Verify() err = %v, want nil", err) + } + if !report.OK() { + t.Fatalf("Verify().Problems = %v, want none", report.Problems) + } + + target := scratch(t) + into := filepath.Join(t.TempDir(), "files") + result, err := Restore(context.Background(), name, RestoreOptions{DSN: target, Files: into}) + if err != nil { + t.Fatalf("Restore() err = %v, want nil", err) + } + if result.Rows != made.TotalRows() { + t.Errorf("Restore().Rows = %d, want %d", result.Rows, made.TotalRows()) + } + if result.FilesPut != 3 { + t.Errorf("Restore().FilesPut = %d, want 3", result.FilesPut) + } + + conn := connect(t, target) + var slug string + if err := conn.QueryRow(context.Background(), `SELECT slug FROM web_site`).Scan(&slug); err != nil { + t.Fatalf("read the restored site err = %v, want nil", err) + } + if slug != "probe" { + t.Errorf("restored site slug = %q, want %q", slug, "probe") + } + body, err := os.ReadFile(filepath.Join(into, "media", "deep", "two.bin")) + if err != nil { + t.Fatalf("read the restored file err = %v, want nil", err) + } + if string(body) != "\x00\x01\x02binary" { + t.Errorf("restored file = %q, want the bytes that went in", body) + } +} + +func TestRestoreLeavesTheIdentityCounterPastTheRestoredRows(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + + target := scratch(t) + if _, err := Restore(context.Background(), name, RestoreOptions{DSN: target}); err != nil { + t.Fatalf("Restore() err = %v, want nil", err) + } + conn := connect(t, target) + ctx := context.Background() + + var was, now int64 + if err := conn.QueryRow(ctx, `SELECT max(id) FROM web_user`).Scan(&was); err != nil { + t.Fatalf("read the restored user err = %v, want nil", err) + } + err := conn.QueryRow(ctx, ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, type, bio, is_forum_active, is_active, can_send_direct_messages, + pending_email, previous_email, language) +VALUES ('!', false, '', '', '', now(), 'probe-next', 'normal', '', true, true, true, '', '', '') +RETURNING id`).Scan(&now) + if err != nil { + t.Fatalf("insert after the restore err = %v, want nil", err) + } + if now <= was { + t.Errorf("the next id = %d, want more than %d", now, was) + } +} + +func TestVerifyReportsADamagedTable(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + damaged := rewrite(t, name, func(head *tar.Header, body []byte) ([]byte, bool) { + if head.Name == dataDir+"/web_site"+dataSuffix { + return append(body, []byte("bogus\n")...), true + } + return body, true + }) + + report, err := Verify(damaged) + if err != nil { + t.Fatalf("Verify() err = %v, want nil", err) + } + if report.OK() { + t.Fatal("Verify().OK() = true, want false") + } + if !mentions(report.Problems, "web_site") { + t.Errorf("Verify().Problems = %v, want one naming web_site", report.Problems) + } +} + +func TestVerifyReportsADamagedFile(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + damaged := rewrite(t, name, func(head *tar.Header, body []byte) ([]byte, bool) { + if head.Name == filesDir+"/one.txt" { + return []byte("tampered"), true + } + return body, true + }) + + report, err := Verify(damaged) + if err != nil { + t.Fatalf("Verify() err = %v, want nil", err) + } + if !mentions(report.Problems, "one.txt") { + t.Errorf("Verify().Problems = %v, want one naming one.txt", report.Problems) + } +} + +func TestVerifyReportsAMissingTable(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + damaged := rewrite(t, name, func(head *tar.Header, body []byte) ([]byte, bool) { + return body, head.Name != dataDir+"/web_site"+dataSuffix + }) + + report, err := Verify(damaged) + if err != nil { + t.Fatalf("Verify() err = %v, want nil", err) + } + if !mentions(report.Problems, "web_site") { + t.Errorf("Verify().Problems = %v, want one naming web_site", report.Problems) + } +} + +func TestVerifyReportsAMigrationThisBuildDoesNotCarry(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + damaged := rewrite(t, name, func(head *tar.Header, body []byte) ([]byte, bool) { + if head.Name != ManifestName { + return body, true + } + var m Manifest + if err := json.Unmarshal(body, &m); err != nil { + t.Fatal(err) + } + m.Migrations = append(m.Migrations, "9999_from_the_future.sql") + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return raw, true + }) + + report, err := Verify(damaged) + if err != nil { + t.Fatalf("Verify() err = %v, want nil", err) + } + if !mentions(report.Problems, "9999_from_the_future.sql") { + t.Errorf("Verify().Problems = %v, want one naming the unknown migration", report.Problems) + } +} + +func TestVerifyRejectsAHalfWrittenArchive(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + whole, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + cut := filepath.Join(t.TempDir(), "cut"+Extension) + if err := os.WriteFile(cut, whole[:len(whole)*2/3], 0o644); err != nil { + t.Fatal(err) + } + + if _, err := Verify(cut); err == nil { + t.Error("Verify(a truncated archive) err = nil, want non-nil") + } +} + +func TestRestoreRefusesADatabaseThatHoldsData(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + + target, _ := filled(t) + _, err := Restore(context.Background(), name, RestoreOptions{DSN: target}) + if err == nil { + t.Fatal("Restore(into a full database) err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "-force") { + t.Errorf("Restore() err = %v, want it to name -force", err) + } +} + +func TestRestoreReplacesAFullDatabaseWithForce(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + + target, _ := filled(t) + conn := connect(t, target) + ctx := context.Background() + if _, err := conn.Exec(ctx, `UPDATE web_site SET title = 'stale'`); err != nil { + t.Fatal(err) + } + conn.Close(ctx) + + if _, err := Restore(ctx, name, RestoreOptions{DSN: target, Force: true}); err != nil { + t.Fatalf("Restore(-force) err = %v, want nil", err) + } + after := connect(t, target) + var title string + if err := after.QueryRow(ctx, `SELECT title FROM web_site`).Scan(&title); err != nil { + t.Fatal(err) + } + if title != "Probe" { + t.Errorf("restored title = %q, want %q", title, "Probe") + } +} + +func TestRestoreRefusesWhileSomethingElseIsConnected(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + + target := scratch(t) + holding := connect(t, target) + if _, err := holding.Exec(context.Background(), `SELECT 1`); err != nil { + t.Fatal(err) + } + + _, err := Restore(context.Background(), name, RestoreOptions{DSN: target}) + if err == nil { + t.Fatal("Restore(with another connection open) err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "stop pwikit") { + t.Errorf("Restore() err = %v, want it to say what to stop", err) + } +} + +func TestRestoreChangesNothingWhenTheBackupIsDamaged(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + damaged := rewrite(t, name, func(head *tar.Header, body []byte) ([]byte, bool) { + if head.Name == dataDir+"/web_user"+dataSuffix { + return append(body, []byte("bogus\n")...), true + } + return body, true + }) + + target, _ := filled(t) + before := connect(t, target) + ctx := context.Background() + var was string + if err := before.QueryRow(ctx, `SELECT title FROM web_site`).Scan(&was); err != nil { + t.Fatal(err) + } + before.Close(ctx) + + if _, err := Restore(ctx, damaged, RestoreOptions{DSN: target, Force: true}); err == nil { + t.Fatal("Restore(a damaged backup) err = nil, want non-nil") + } + after := connect(t, target) + var now string + if err := after.QueryRow(ctx, `SELECT title FROM web_site`).Scan(&now); err != nil { + t.Fatalf("the database is unusable after a refused restore: %v", err) + } + if now != was { + t.Errorf("title after a refused restore = %q, want it untouched at %q", now, was) + } +} + +func TestListReportsAnArchiveItCannotRead(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + + dir := t.TempDir() + body, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "good"+Extension), body, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "junk"+Extension), []byte("not an archive"), 0o644); err != nil { + t.Fatal(err) + } + + found, err := List(dir) + if err != nil { + t.Fatalf("List() err = %v, want nil", err) + } + if len(found) != 2 { + t.Fatalf("len(List()) = %d, want 2", len(found)) + } + var problems int + for _, one := range found { + if one.Problem != "" { + problems++ + } + } + if problems != 1 { + t.Errorf("List() reported %d unreadable archives, want 1", problems) + } +} + +func rewrite(t *testing.T, name string, change func(*tar.Header, []byte) ([]byte, bool)) string { + t.Helper() + in, err := os.Open(name) + if err != nil { + t.Fatal(err) + } + defer in.Close() + + zin, err := gzip.NewReader(in) + if err != nil { + t.Fatal(err) + } + defer zin.Close() + + out := filepath.Join(t.TempDir(), "changed"+Extension) + file, err := os.Create(out) + if err != nil { + t.Fatal(err) + } + defer file.Close() + + zout := gzip.NewWriter(file) + tw := tar.NewWriter(zout) + tr := tar.NewReader(zin) + for { + head, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + body, keep := change(head, body) + if !keep { + continue + } + head.Size = int64(len(body)) + if err := tw.WriteHeader(head); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zout.Close(); err != nil { + t.Fatal(err) + } + return out +} + +func mentions(problems []string, want string) bool { + for _, one := range problems { + if strings.Contains(one, want) { + return true + } + } + return false +} + +func TestEveryTableSaysWhetherItBelongsToASite(t *testing.T) { + dsn := scratch(t) + if _, err := migrate.Run(context.Background(), dsn); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("Connect() err = %v, want nil", err) + } + defer conn.Close(ctx) + + present, err := db.BackupTables(ctx, conn) + if err != nil { + t.Fatalf("BackupTables() err = %v, want nil", err) + } + ruled := map[string]bool{} + for _, name := range db.SiteScopeRuled() { + ruled[name] = true + } + for _, name := range present { + if !ruled[name] { + t.Errorf("rules[%q] is missing, want a rule saying whether it belongs to a site", name) + } + delete(ruled, name) + } + for name := range ruled { + t.Errorf("rules[%q] names a table the schema does not have", name) + } +} + +func TestEveryScopedQueryRuns(t *testing.T) { + dsn, _ := filled(t) + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("Connect() err = %v, want nil", err) + } + defer conn.Close(ctx) + + present, err := db.BackupTables(ctx, conn) + if err != nil { + t.Fatal(err) + } + for _, name := range present { + for _, keep := range []bool{true, false} { + query, err := db.SiteExportQuery(ctx, conn, name, keep) + if err != nil { + t.Errorf("SiteExportQuery(%q) err = %v, want nil", name, err) + continue + } + if query == "" { + continue + } + if _, err := conn.Exec(ctx, `SELECT count(*) FROM (`+ + strings.ReplaceAll(query, "$1", `'probe'`)+`) q`); err != nil { + t.Errorf("the rule for %q does not run: %v", name, err) + } + } + } +} + +func twoSites(t *testing.T) string { + t.Helper() + dsn := scratch(t) + if _, err := migrate.Run(context.Background(), dsn); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + ctx := context.Background() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatalf("Connect() err = %v, want nil", err) + } + defer conn.Close(ctx) + + for _, slug := range []string{"leaving", "staying"} { + _, err := conn.Exec(ctx, ` +INSERT INTO web_site (slug, title, headline, domain, media_domain, home_page, + footer_license, signup_notice, password_help, email_policy, membership_password, + membership_password_enabled, language) +VALUES ($1, $1, '', $1 || '.test', 'media.' || $1 || '.test', 'main', '', '', '', 'optional', '', false, 'zh-hans')`, slug) + if err != nil { + t.Fatalf("insert site %s err = %v, want nil", slug, err) + } + var user int64 + err = conn.QueryRow(ctx, ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, type, bio, is_forum_active, is_active, can_send_direct_messages, + pending_email, previous_email, language) +VALUES ('secret-hash', false, '', '', '', now(), $1 || '-author', 'normal', '', true, true, true, '', '', '') +RETURNING id`, slug).Scan(&user) + if err != nil { + t.Fatalf("insert user for %s err = %v, want nil", slug, err) + } + var article int64 + err = conn.QueryRow(ctx, ` +INSERT INTO web_article (site_id, category, name, title, locked, created_at, updated_at, media_name) +VALUES ((SELECT id FROM web_site WHERE slug = $1), '_default', $1 || '-page', $1, false, now(), now(), $1) +RETURNING id`, slug).Scan(&article) + if err != nil { + t.Fatalf("insert article for %s err = %v, want nil", slug, err) + } + if _, err := conn.Exec(ctx, + `INSERT INTO web_article_authors (article_id, user_id) VALUES ($1, $2)`, article, user); err != nil { + t.Fatalf("insert authorship for %s err = %v, want nil", slug, err) + } + } + _, err = conn.Exec(ctx, ` +INSERT INTO web_externallink (link_from, link_to, link_type, from_site_id, to_site_id) +VALUES ('leaving:page', 'staying:page', 'internal', + (SELECT id FROM web_site WHERE slug = 'leaving'), + (SELECT id FROM web_site WHERE slug = 'staying'))`) + if err != nil { + t.Fatalf("insert cross site link err = %v, want nil", err) + } + + _, err = conn.Exec(ctx, ` +INSERT INTO web_directmessage (sender_id, recipient_id, body, created_at, is_read) +SELECT a.id, b.id, 'private', now(), false +FROM web_user a, web_user b WHERE a.username = 'leaving-author' AND b.username = 'staying-author'`) + if err != nil { + t.Fatalf("insert direct message err = %v, want nil", err) + } + return dsn +} + +func TestSiteBackupCarriesOneSiteAndLeavesTheOther(t *testing.T) { + source := twoSites(t) + out := filepath.Join(t.TempDir(), "one"+Extension) + result, err := Create(context.Background(), CreateOptions{DSN: source, Output: out, Site: "leaving"}) + if err != nil { + t.Fatalf("Create(-site) err = %v, want nil", err) + } + if result.Manifest.Site != "leaving" { + t.Errorf("Create().Manifest.Site = %q, want %q", result.Manifest.Site, "leaving") + } + if got := result.Manifest.Tables["web_site"].Rows; got != 1 { + t.Errorf("web_site rows = %d, want 1", got) + } + if got := result.Manifest.Tables["web_directmessage"].Rows; got != 0 { + t.Errorf("web_directmessage rows = %d, want 0", got) + } + + report, err := Verify(out) + if err != nil { + t.Fatalf("Verify() err = %v, want nil", err) + } + if !report.OK() { + t.Fatalf("Verify().Problems = %v, want none", report.Problems) + } + + target := scratch(t) + if _, err := Restore(context.Background(), out, RestoreOptions{DSN: target}); err != nil { + t.Fatalf("Restore() err = %v, want nil", err) + } + conn := connect(t, target) + ctx := context.Background() + + var slugs string + if err := conn.QueryRow(ctx, `SELECT coalesce(string_agg(slug, ','), '') FROM web_site`).Scan(&slugs); err != nil { + t.Fatal(err) + } + if slugs != "leaving" { + t.Errorf("restored sites = %q, want %q", slugs, "leaving") + } + var names string + if err := conn.QueryRow(ctx, `SELECT coalesce(string_agg(username, ','), '') FROM web_user ORDER BY 1`).Scan(&names); err != nil { + t.Fatal(err) + } + if names != "leaving-author" { + t.Errorf("restored users = %q, want only the one the site points at", names) + } + var mail int + if err := conn.QueryRow(ctx, `SELECT count(*) FROM web_directmessage`).Scan(&mail); err != nil { + t.Fatal(err) + } + if mail != 0 { + t.Errorf("restored direct messages = %d, want 0", mail) + } + + var from, to *int64 + err = conn.QueryRow(ctx, `SELECT from_site_id, to_site_id FROM web_externallink`).Scan(&from, &to) + if err != nil { + t.Fatalf("read the restored link err = %v, want nil", err) + } + if from == nil { + t.Error("the restored link has no site it came from, want one") + } + if to != nil { + t.Errorf("the restored link still names site %d as its target, want none", *to) + } +} + +func TestSiteBackupBlanksThePasswordUnlessAsked(t *testing.T) { + source := twoSites(t) + ctx := context.Background() + + for _, keep := range []bool{false, true} { + out := filepath.Join(t.TempDir(), "one"+Extension) + if _, err := Create(ctx, CreateOptions{DSN: source, Output: out, Site: "leaving", KeepPasswords: keep}); err != nil { + t.Fatalf("Create(keep=%t) err = %v, want nil", keep, err) + } + target := scratch(t) + if _, err := Restore(ctx, out, RestoreOptions{DSN: target}); err != nil { + t.Fatalf("Restore(keep=%t) err = %v, want nil", keep, err) + } + conn, err := pgx.Connect(ctx, target) + if err != nil { + t.Fatal(err) + } + var stored string + if err := conn.QueryRow(ctx, `SELECT password FROM web_user WHERE username = 'leaving-author'`).Scan(&stored); err != nil { + conn.Close(ctx) + t.Fatal(err) + } + conn.Close(ctx) + + want := "!" + if keep { + want = "secret-hash" + } + if stored != want { + t.Errorf("password with KeepPasswords=%t = %q, want %q", keep, stored, want) + } + } +} + +func TestSiteBackupLeavesTheOldOperatorsRightsBehind(t *testing.T) { + source := twoSites(t) + ctx := context.Background() + conn, err := pgx.Connect(ctx, source) + if err != nil { + t.Fatal(err) + } + _, err = conn.Exec(ctx, + `UPDATE web_user SET is_superuser = true, api_key = 'operator-key' WHERE username = 'leaving-author'`) + conn.Close(ctx) + if err != nil { + t.Fatalf("make the author a superuser err = %v, want nil", err) + } + + out := filepath.Join(t.TempDir(), "one"+Extension) + if _, err := Create(ctx, CreateOptions{DSN: source, Output: out, Site: "leaving", KeepPasswords: true}); err != nil { + t.Fatalf("Create(-site) err = %v, want nil", err) + } + target := scratch(t) + if _, err := Restore(ctx, out, RestoreOptions{DSN: target}); err != nil { + t.Fatalf("Restore() err = %v, want nil", err) + } + after := connect(t, target) + + var super bool + var key *string + err = after.QueryRow(ctx, + `SELECT is_superuser, api_key FROM web_user WHERE username = 'leaving-author'`).Scan(&super, &key) + if err != nil { + t.Fatal(err) + } + if super { + t.Error("is_superuser after a site backup = true, want false") + } + if key != nil { + t.Errorf("api_key after a site backup = %q, want none", *key) + } +} + +func TestCheckServerLetsAServerNewEnoughThrough(t *testing.T) { + dsn := requireDSN(t) + found, err := CheckServer(context.Background(), dsn) + if err != nil { + t.Fatalf("CheckServer() err = %v, want nil", err) + } + if found < MinimumPGVersion { + t.Errorf("CheckServer() = %d, want at least %d", found, MinimumPGVersion) + } +} + +func TestTooOldSaysWhatToRun(t *testing.T) { + err := TooOld(120000) + for _, want := range []string{"12.0", "14.0", "pwikit backup create", "pwikit backup restore"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Contains(TooOld(120000), %q) = false, want true", want) + } + } +} + +func TestDescribeReadsBackAsAVersion(t *testing.T) { + for _, c := range []struct { + num int + want string + }{{140023, "14.23"}, {170000, "17.0"}, {0, "unknown"}} { + if got := Describe(c.num); got != c.want { + t.Errorf("Describe(%d) = %q, want %q", c.num, got, c.want) + } + } +} + +func TestReadyOnABlankDatabase(t *testing.T) { + target := scratch(t) + holdsData, err := Ready(context.Background(), target, false) + if err != nil { + t.Fatalf("Ready(blank) err = %v, want nil", err) + } + if holdsData { + t.Error("Ready(blank) holdsData = true, want false") + } +} + +func TestReadyOnADatabaseOnlyMigrated(t *testing.T) { + target := scratch(t) + ctx := context.Background() + if _, err := migrate.Run(ctx, target); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + holdsData, err := readyWhenAlone(t, target) + if err != nil { + t.Fatalf("Ready(migrated) err = %v, want nil", err) + } + if holdsData { + t.Error("Ready(migrated) holdsData = true, want false") + } +} + +func readyWhenAlone(t *testing.T, dsn string) (bool, error) { + t.Helper() + var ( + holdsData bool + err error + ) + for i := 0; i < 100; i++ { + holdsData, err = Ready(context.Background(), dsn, false) + if err == nil || !strings.Contains(err.Error(), "other connections") { + return holdsData, err + } + time.Sleep(100 * time.Millisecond) + } + return holdsData, err +} + +func waitAlone(t *testing.T, dsn string) { + t.Helper() + if _, err := readyWhenAlone(t, dsn); err != nil && strings.Contains(err.Error(), "other connections") { + t.Fatalf("waiting for the database to be free err = %v, want nil", err) + } +} + +func TestSeededNamesEveryTableTheMigrationsFill(t *testing.T) { + target := scratch(t) + ctx := context.Background() + if _, err := migrate.Run(ctx, target); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + conn := connect(t, target) + tables, err := db.BackupTables(ctx, conn) + if err != nil { + t.Fatal(err) + } + full, err := db.NonEmptyTables(ctx, conn, tables) + if err != nil { + t.Fatal(err) + } + for _, name := range full { + if !seeded[name] { + t.Errorf("seeded[%q] = false, want true", name) + } + } +} + +func TestReadyOnADatabaseThatHoldsData(t *testing.T) { + target, _ := filled(t) + holdsData, err := Ready(context.Background(), target, true) + if err != nil { + t.Fatalf("Ready(full, force) err = %v, want nil", err) + } + if !holdsData { + t.Error("Ready(full, force) holdsData = false, want true") + } +} + +func TestRestoreIntoAMigratedDatabaseWithoutForce(t *testing.T) { + source, files := filled(t) + name, _ := create(t, source, files) + + target := scratch(t) + ctx := context.Background() + if _, err := migrate.Run(ctx, target); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + waitAlone(t, target) + if _, err := Restore(ctx, name, RestoreOptions{DSN: target}); err != nil { + t.Fatalf("Restore(into a migrated database) err = %v, want nil", err) + } + conn := connect(t, target) + var slug string + if err := conn.QueryRow(ctx, `SELECT slug FROM web_site`).Scan(&slug); err != nil { + t.Fatalf("read the restored site err = %v, want nil", err) + } + if slug != "probe" { + t.Errorf("restored site slug = %q, want %q", slug, "probe") + } +} diff --git a/internal/backup/check.go b/internal/backup/check.go new file mode 100644 index 00000000..7dc89d59 --- /dev/null +++ b/internal/backup/check.go @@ -0,0 +1,29 @@ +package backup + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + + "github.com/WikitTeam/ProjectWikit/internal/db" +) + +// This only answers for a server somebody else runs. The bundled one is read +// off its own data directory before it is even started. +func CheckServer(ctx context.Context, dsn string) (int, error) { + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return 0, fmt.Errorf("connect to check the postgres version: %w", err) + } + defer conn.Close(ctx) + + found, err := db.ServerVersion(ctx, conn) + if err != nil { + return 0, err + } + if found < MinimumPGVersion { + return found, TooOld(found) + } + return found, nil +} diff --git a/internal/backup/create.go b/internal/backup/create.go new file mode 100644 index 00000000..ad5b987b --- /dev/null +++ b/internal/backup/create.go @@ -0,0 +1,329 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/version" +) + +type CreateOptions struct { + DSN string + Files string + Output string + // Site narrows the backup to one site, leaving the rest of the instance + // behind. Empty takes everything. + Site string + KeepPasswords bool + Report func(string) +} + +type CreateResult struct { + Path string + Manifest Manifest + Bytes int64 +} + +func Create(ctx context.Context, opts CreateOptions) (CreateResult, error) { + if opts.Output == "" { + return CreateResult{}, fmt.Errorf("no output path") + } + conn, err := pgx.Connect(ctx, opts.DSN) + if err != nil { + return CreateResult{}, fmt.Errorf("connect to back up: %w", err) + } + defer conn.Close(ctx) + + staged, m, err := stage(ctx, conn, opts) + defer staged.remove() + if err != nil { + return CreateResult{}, err + } + + if err := os.MkdirAll(filepath.Dir(opts.Output), 0o755); err != nil { + return CreateResult{}, err + } + // Written beside the target so the rename cannot cross a filesystem, and so + // an interrupted run leaves a name nothing mistakes for a backup. + temp, err := os.CreateTemp(filepath.Dir(opts.Output), filepath.Base(opts.Output)+".partial-*") + if err != nil { + return CreateResult{}, err + } + defer os.Remove(temp.Name()) + defer temp.Close() + + report(opts.Report, "writing "+filepath.Base(opts.Output)) + if err := writeArchive(temp, m, staged, opts.Files); err != nil { + return CreateResult{}, err + } + size, err := temp.Seek(0, io.SeekCurrent) + if err != nil { + return CreateResult{}, err + } + if err := temp.Close(); err != nil { + return CreateResult{}, err + } + if err := os.Rename(temp.Name(), opts.Output); err != nil { + return CreateResult{}, err + } + return CreateResult{Path: opts.Output, Manifest: m, Bytes: size}, nil +} + +// The manifest cannot be finished until every checksum is in, yet it has to be +// the first entry, so each table waits in a scratch file. +type staging struct { + order []string + paths map[string]string +} + +func (s staging) remove() { + for _, name := range s.paths { + if name != "" { + os.Remove(name) + } + } +} + +func stage(ctx context.Context, conn *pgx.Conn, opts CreateOptions) (staging, Manifest, error) { + staged := staging{paths: map[string]string{}} + m := Manifest{ + Format: Format, + CreatedAt: time.Now().UTC(), + Pwikit: version.String(), + Site: opts.Site, + Tables: map[string]Table{}, + Files: Files{Entries: map[string]string{}}, + } + if opts.Site != "" { + known, err := db.SiteSlugExists(ctx, conn, opts.Site) + if err != nil { + return staged, m, err + } + if !known { + return staged, m, fmt.Errorf("no site has the slug %q", opts.Site) + } + m.KeptPasswords = opts.KeepPasswords + } + var err error + if m.PGVersion, err = db.ServerVersion(ctx, conn); err != nil { + return staged, m, err + } + if m.Migrations, err = db.AppliedMigrations(ctx, conn); err != nil { + return staged, m, err + } + + // Every table is read inside one snapshot, so the rows of one cannot be + // newer than the rows of another. + tx, err := conn.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) + if err != nil { + return staged, m, fmt.Errorf("open the snapshot: %w", err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + tables, err := db.BackupTables(ctx, conn) + if err != nil { + return staged, m, err + } + for i, name := range tables { + report(opts.Report, fmt.Sprintf("[%d/%d] %s", i+1, len(tables), name)) + query := "" + if opts.Site != "" { + scoped, err := db.SiteExportQuery(ctx, conn, name, opts.KeepPasswords) + if err != nil { + return staged, m, err + } + if scoped == "" { + m.Tables[name] = Table{SHA256: emptySHA256} + staged.order = append(staged.order, name) + staged.paths[name] = "" + continue + } + slug, err := db.QuoteLiteral(ctx, tx, opts.Site) + if err != nil { + return staged, m, err + } + query = strings.ReplaceAll(scoped, "$1", slug) + } + t, at, err := copyTable(ctx, tx, name, query) + if err != nil { + return staged, m, err + } + m.Tables[name] = t + staged.order = append(staged.order, name) + staged.paths[name] = at + } + if err := tx.Rollback(ctx); err != nil && !strings.Contains(err.Error(), "closed") { + return staged, m, err + } + + // The files are read after the rows, so one deleted while this ran is still + // in the archive rather than missing from it. + if opts.Files != "" { + report(opts.Report, "reading files") + if err := hashFiles(opts.Files, &m); err != nil { + return staged, m, err + } + } + return staged, m, nil +} + +// A tar entry needs its size up front, so each table lands in a scratch file. +// The digest is taken on the way there, which keeps it to one pass. +func copyTable(ctx context.Context, tx pgx.Tx, name, query string) (Table, string, error) { + scratch, err := os.CreateTemp("", "pwbak-*") + if err != nil { + return Table{}, "", err + } + defer scratch.Close() + + sum := sha256.New() + rows, err := db.CopyOut(ctx, tx, io.MultiWriter(scratch, sum), name, query) + if err != nil { + os.Remove(scratch.Name()) + return Table{}, "", err + } + size, err := scratch.Seek(0, io.SeekCurrent) + if err != nil { + os.Remove(scratch.Name()) + return Table{}, "", err + } + return Table{ + Rows: rows, + Bytes: size, + SHA256: hex.EncodeToString(sum.Sum(nil)), + }, scratch.Name(), nil +} + +func hashFiles(root string, m *Manifest) error { + m.Files.Included = true + return filepath.WalkDir(root, func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || !entry.Type().IsRegular() { + return nil + } + rel, err := filepath.Rel(root, name) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + file, err := os.Open(name) + if err != nil { + return err + } + defer file.Close() + + sum := sha256.New() + if _, err := io.Copy(sum, file); err != nil { + return err + } + m.Files.Entries[filepath.ToSlash(rel)] = hex.EncodeToString(sum.Sum(nil)) + m.Files.Count++ + m.Files.Bytes += info.Size() + return nil + }) +} + +func writeArchive(w io.Writer, m Manifest, staged staging, filesRoot string) error { + zip := gzip.NewWriter(w) + tw := tar.NewWriter(zip) + + if err := writeManifest(tw, m); err != nil { + return err + } + for _, name := range staged.order { + if staged.paths[name] == "" { + if err := writeEmptyEntry(tw, path.Join(dataDir, name+dataSuffix)); err != nil { + return err + } + continue + } + if err := writeFileEntry(tw, path.Join(dataDir, name+dataSuffix), staged.paths[name]); err != nil { + return err + } + } + if m.Files.Included { + rels := make([]string, 0, len(m.Files.Entries)) + for rel := range m.Files.Entries { + rels = append(rels, rel) + } + sort.Strings(rels) + for _, rel := range rels { + from := filepath.Join(filesRoot, filepath.FromSlash(rel)) + if err := writeFileEntry(tw, path.Join(filesDir, rel), from); err != nil { + return err + } + } + } + if err := tw.Close(); err != nil { + return err + } + return zip.Close() +} + +func writeFileEntry(tw *tar.Writer, as, from string) error { + file, err := os.Open(from) + if err != nil { + return err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{Name: as, Mode: 0o600, Size: info.Size()}); err != nil { + return err + } + _, err = io.Copy(tw, file) + return err +} + +func writeEmptyEntry(tw *tar.Writer, as string) error { + return tw.WriteHeader(&tar.Header{Name: as, Mode: 0o600, Size: 0}) +} + +func writeManifest(tw *tar.Writer, m Manifest) error { + var body strings.Builder + if err := m.write(&body); err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{Name: ManifestName, Mode: 0o600, Size: int64(body.Len())}); err != nil { + return err + } + _, err := io.WriteString(tw, body.String()) + return err +} + +func DefaultName(at time.Time, site string) string { + name := "pwikit-" + if site != "" { + name += site + "-" + } + return name + at.UTC().Format("20060102-150405") + Extension +} + +func report(f func(string), line string) { + if f != nil { + f(line) + } +} diff --git a/internal/backup/db.go b/internal/backup/db.go new file mode 100644 index 00000000..b6d8d7c1 --- /dev/null +++ b/internal/backup/db.go @@ -0,0 +1,26 @@ +package backup + +import "fmt" + +// A newer server is not refused, since nothing in the schema depends on what +// the versions above this one changed. +const MinimumPGVersion = 140000 + +func Describe(num int) string { + if num == 0 { + return "unknown" + } + return fmt.Sprintf("%d.%d", num/10000, num%10000) +} + +// TooOld is what a server below MinimumPGVersion gets told, in the words of +// somebody who has never heard of a server_version_num. +func TooOld(found int) error { + return fmt.Errorf( + "this database is PostgreSQL %s, and pwikit needs %s or newer.\n"+ + " Upgrade PostgreSQL, or point pwikit at a newer server with -database.\n"+ + " To move the data: back it up with the pwikit you are running now\n"+ + " (pwikit backup create), then restore it into the new server\n"+ + " (pwikit backup restore -database ).", + Describe(found), Describe(MinimumPGVersion)) +} diff --git a/internal/backup/list.go b/internal/backup/list.go new file mode 100644 index 00000000..ca010473 --- /dev/null +++ b/internal/backup/list.go @@ -0,0 +1,46 @@ +package backup + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +type Entry struct { + Path string + Bytes int64 + Manifest Manifest + Problem string +} + +// A file this cannot read is reported rather than skipped, since a backup +// nobody can open is the one worth hearing about. +func List(dir string) ([]Entry, error) { + found, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var out []Entry + for _, one := range found { + if one.IsDir() || !strings.HasSuffix(one.Name(), Extension) { + continue + } + full := filepath.Join(dir, one.Name()) + entry := Entry{Path: full} + if info, err := one.Info(); err == nil { + entry.Bytes = info.Size() + } + m, err := ReadManifestOf(full) + if err != nil { + entry.Problem = err.Error() + } else { + entry.Manifest = m + } + out = append(out, entry) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Manifest.CreatedAt.After(out[j].Manifest.CreatedAt) + }) + return out, nil +} diff --git a/internal/backup/manifest.go b/internal/backup/manifest.go new file mode 100644 index 00000000..f22536c4 --- /dev/null +++ b/internal/backup/manifest.go @@ -0,0 +1,83 @@ +package backup + +import ( + "encoding/json" + "fmt" + "io" + "time" +) + +const ( + // Format is the version of the layout inside the archive, not of pwikit. It + // changes only when an older build could no longer read a newer file. + Format = 1 + + ManifestName = "manifest.json" + dataDir = "data" + filesDir = "files" + dataSuffix = ".copy" + + Extension = ".pwbak" + + // The digest of nothing, which is what a table a site export leaves behind + // has to carry so verify can tell it from a table that went missing. + emptySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +) + +type Table struct { + Rows int64 `json:"rows"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` +} + +type Files struct { + Included bool `json:"included"` + Count int `json:"count"` + Bytes int64 `json:"bytes"` + Entries map[string]string `json:"entries"` +} + +type Manifest struct { + Format int `json:"format"` + CreatedAt time.Time `json:"created_at"` + Pwikit string `json:"pwikit_version"` + PGVersion int `json:"pg_version"` + Migrations []string `json:"migrations"` + // Site is empty for a whole instance and the slug when one site was taken + // out on its own. + Site string `json:"site,omitempty"` + KeptPasswords bool `json:"kept_passwords,omitempty"` + Tables map[string]Table `json:"tables"` + Files Files `json:"files"` +} + +func (m Manifest) TotalRows() int64 { + var n int64 + for _, t := range m.Tables { + n += t.Rows + } + return n +} + +func (m Manifest) write(w io.Writer) error { + raw, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + _, err = w.Write(append(raw, '\n')) + return err +} + +func readManifest(r io.Reader) (Manifest, error) { + var m Manifest + if err := json.NewDecoder(r).Decode(&m); err != nil { + return Manifest{}, fmt.Errorf("read %s: %w", ManifestName, err) + } + if m.Format == 0 { + return Manifest{}, fmt.Errorf("%s names no format version", ManifestName) + } + if m.Format > Format { + return Manifest{}, fmt.Errorf("the backup is format %d and this build reads up to %d; run the newer pwikit", m.Format, Format) + } + return m, nil +} diff --git a/internal/backup/restore.go b/internal/backup/restore.go new file mode 100644 index 00000000..4de2dc61 --- /dev/null +++ b/internal/backup/restore.go @@ -0,0 +1,322 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/migrate" +) + +type RestoreOptions struct { + DSN string + Files string + Force bool + Report func(string) +} + +type RestoreResult struct { + Manifest Manifest + Rows int64 + FilesPut int + MigratedUp []string + FilesMoved bool + ReplacedDir string +} + +var ErrNotEmpty = errors.New("the database already holds data") + +// The migrations fill these before anyone uses the database, so rows in them +// alone leave nothing to replace or keep. +var seeded = map[string]bool{ + "auth_permission": true, + "django_content_type": true, + "web_role": true, + "web_role_permissions": true, + "web_rolecategory": true, + "web_theme": true, +} + +func Ready(ctx context.Context, dsn string, force bool) (holdsData bool, err error) { + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return false, fmt.Errorf("connect to restore: %w", err) + } + defer conn.Close(ctx) + return readyToRestore(ctx, conn, force) +} + +func Restore(ctx context.Context, name string, opts RestoreOptions) (RestoreResult, error) { + var out RestoreResult + + report(opts.Report, "checking the backup") + check, err := Verify(name) + if err != nil { + return out, err + } + if !check.OK() { + return out, fmt.Errorf("the backup did not pass its check, so nothing was changed:\n %s", + strings.Join(check.Problems, "\n ")) + } + m := check.Manifest + out.Manifest = m + + conn, err := pgx.Connect(ctx, opts.DSN) + if err != nil { + return out, fmt.Errorf("connect to restore: %w", err) + } + defer conn.Close(ctx) + + if _, err := readyToRestore(ctx, conn, opts.Force); err != nil { + return out, err + } + + report(opts.Report, "rebuilding the schema") + tx, err := conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return out, err + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if err := db.ResetSchema(ctx, tx); err != nil { + return out, err + } + if err := migrate.ApplyInto(ctx, tx, m.Migrations); err != nil { + return out, err + } + // The baseline seeds permissions, content types and the built-in roles, and + // the backup carries its own copy of all of them. + if err := emptyEverything(ctx, tx); err != nil { + return out, err + } + keys, err := db.LiftForeignKeys(ctx, tx) + if err != nil { + return out, err + } + + rows, err := loadTables(ctx, tx, name, m, opts.Report) + if err != nil { + return out, err + } + out.Rows = rows + + report(opts.Report, "checking the references") + if err := db.RestoreForeignKeys(ctx, tx, keys); err != nil { + return out, err + } + if err := db.ResetIdentities(ctx, tx, named(m)); err != nil { + return out, err + } + if err := tx.Commit(ctx); err != nil { + return out, fmt.Errorf("commit the restore: %w", err) + } + + // The rest of the migrations run only once the data is in, so a backup + // taken by an older build comes forward instead of being refused. + out.MigratedUp, err = catchUp(ctx, opts.DSN, m) + if err != nil { + return out, err + } + + if m.Files.Included && opts.Files != "" { + report(opts.Report, "putting the files back") + if err := restoreFiles(name, opts.Files, m, &out); err != nil { + return out, err + } + } + return out, nil +} + +func emptyEverything(ctx context.Context, tx pgx.Tx) error { + tables, err := db.BackupTables(ctx, tx.Conn()) + if err != nil { + return err + } + return db.TruncateAll(ctx, tx, tables) +} + +func named(m Manifest) map[string]bool { + out := make(map[string]bool, len(m.Tables)) + for name := range m.Tables { + out[name] = true + } + return out +} + +func readyToRestore(ctx context.Context, conn *pgx.Conn, force bool) (bool, error) { + others, err := db.OtherConnections(ctx, conn) + if err != nil { + return false, err + } + if others > 0 { + return false, fmt.Errorf("%d other connections are using this database; stop pwikit before restoring", others) + } + tables, err := db.BackupTables(ctx, conn) + if err != nil { + return false, err + } + var used []string + for _, name := range tables { + if !seeded[name] { + used = append(used, name) + } + } + full, err := db.NonEmptyTables(ctx, conn, used) + if err != nil { + return false, err + } + if len(full) > 0 && !force { + return true, fmt.Errorf("%w (%s and %d more tables); run again with -force to replace it", + ErrNotEmpty, full[0], len(full)-1) + } + return len(full) > 0, nil +} + +func loadTables(ctx context.Context, tx pgx.Tx, name string, m Manifest, out func(string)) (int64, error) { + // The references are off while this runs, so the tables can arrive in any + // order and this stays one pass. + file, err := os.Open(name) + if err != nil { + return 0, err + } + defer file.Close() + + zip, err := gzip.NewReader(file) + if err != nil { + return 0, err + } + defer zip.Close() + + var total int64 + done := 0 + tr := tar.NewReader(zip) + for { + head, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return total, err + } + if !strings.HasPrefix(head.Name, dataDir+"/") { + if _, err := io.Copy(io.Discard, tr); err != nil { + return total, err + } + continue + } + table := strings.TrimSuffix(path.Base(head.Name), dataSuffix) + done++ + report(out, fmt.Sprintf("[%d/%d] %s", done, len(m.Tables), table)) + rows, err := db.CopyIn(ctx, tx, tr, table) + if err != nil { + return total, err + } + total += rows + } + return total, nil +} + +func catchUp(ctx context.Context, dsn string, m Manifest) ([]string, error) { + if len(m.Migrations) == len(migrate.Names()) { + return nil, nil + } + result, err := migrate.Run(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("bring the schema up to this build: %w", err) + } + return result.Applied, nil +} + +// The files land beside the real directory and only swap in once every one of +// them is written, so an interrupted restore leaves the old tree alone. +func restoreFiles(archive, root string, m Manifest, out *RestoreResult) error { + staging := root + ".restoring" + if err := os.RemoveAll(staging); err != nil { + return err + } + if err := os.MkdirAll(staging, 0o755); err != nil { + return err + } + file, err := os.Open(archive) + if err != nil { + return err + } + defer file.Close() + + zip, err := gzip.NewReader(file) + if err != nil { + return err + } + defer zip.Close() + + tr := tar.NewReader(zip) + for { + head, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if !strings.HasPrefix(head.Name, filesDir+"/") { + if _, err := io.Copy(io.Discard, tr); err != nil { + return err + } + continue + } + rel := strings.TrimPrefix(head.Name, filesDir+"/") + to, err := safeJoin(staging, rel) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { + return err + } + written, err := os.Create(to) + if err != nil { + return err + } + if _, err := io.Copy(written, tr); err != nil { + written.Close() + return err + } + if err := written.Close(); err != nil { + return err + } + out.FilesPut++ + } + + retired := root + ".replaced" + if err := os.RemoveAll(retired); err != nil { + return err + } + if _, err := os.Stat(root); err == nil { + if err := os.Rename(root, retired); err != nil { + return err + } + out.ReplacedDir = retired + } + if err := os.Rename(staging, root); err != nil { + return err + } + out.FilesMoved = true + return nil +} + +func safeJoin(root, rel string) (string, error) { + clean := filepath.Clean(filepath.FromSlash(rel)) + if filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") { + return "", fmt.Errorf("the archive names %q, which points outside the files directory", rel) + } + return filepath.Join(root, clean), nil +} diff --git a/internal/backup/verify.go b/internal/backup/verify.go new file mode 100644 index 00000000..d25e2280 --- /dev/null +++ b/internal/backup/verify.go @@ -0,0 +1,225 @@ +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path" + "sort" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/migrate" +) + +type Report struct { + Manifest Manifest + Problems []string + Notes []string +} + +func (r Report) OK() bool { return len(r.Problems) == 0 } + +func (r *Report) problem(format string, args ...any) { + r.Problems = append(r.Problems, fmt.Sprintf(format, args...)) +} + +func (r *Report) note(format string, args ...any) { + r.Notes = append(r.Notes, fmt.Sprintf(format, args...)) +} + +// Verify reads the whole archive rather than trusting the manifest, and keeps +// going after the first complaint so one run tells you everything. +func Verify(name string) (Report, error) { + var r Report + + m, err := ReadManifestOf(name) + if err != nil { + return r, err + } + r.Manifest = m + + file, err := os.Open(name) + if err != nil { + return r, err + } + defer file.Close() + + zip, err := gzip.NewReader(file) + if err != nil { + return r, fmt.Errorf("%s is not gzip: %w", name, err) + } + defer zip.Close() + + seenTables := map[string]bool{} + seenFiles := map[string]bool{} + tr := tar.NewReader(zip) + for { + head, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return r, fmt.Errorf("read %s: %w", name, err) + } + switch { + case head.Name == ManifestName: + if _, err := io.Copy(io.Discard, tr); err != nil { + return r, err + } + case strings.HasPrefix(head.Name, dataDir+"/"): + table := strings.TrimSuffix(path.Base(head.Name), dataSuffix) + seenTables[table] = true + checkTable(&r, tr, table, m.Tables[table]) + case strings.HasPrefix(head.Name, filesDir+"/"): + rel := strings.TrimPrefix(head.Name, filesDir+"/") + seenFiles[rel] = true + checkFile(&r, tr, rel, m.Files.Entries[rel]) + default: + r.problem("the archive holds %q, which does not belong to this format", head.Name) + if _, err := io.Copy(io.Discard, tr); err != nil { + return r, err + } + } + } + + for table := range m.Tables { + if !seenTables[table] { + r.problem("the manifest names table %s but the archive has no data for it", table) + } + } + if m.Files.Included { + for rel := range m.Files.Entries { + if !seenFiles[rel] { + r.problem("the manifest names file %s but the archive does not hold it", rel) + } + } + } + checkMigrations(&r, m) + sort.Strings(r.Problems) + return r, nil +} + +func checkTable(r *Report, body io.Reader, table string, want Table) { + if want.SHA256 == "" { + r.problem("the archive holds data for %s, which the manifest does not name", table) + io.Copy(io.Discard, body) + return + } + sum := sha256.New() + rows, size, err := countRows(io.TeeReader(body, sum)) + if err != nil { + r.problem("read the data of %s: %v", table, err) + return + } + if got := hex.EncodeToString(sum.Sum(nil)); got != want.SHA256 { + r.problem("%s is damaged; its checksum is %s and the manifest says %s", table, short(got), short(want.SHA256)) + } + if size != want.Bytes { + r.problem("%s holds %d bytes and the manifest says %d", table, size, want.Bytes) + } + if rows != want.Rows { + r.problem("%s holds %d rows and the manifest says %d", table, rows, want.Rows) + } +} + +func checkFile(r *Report, body io.Reader, rel, want string) { + sum := sha256.New() + if _, err := io.Copy(sum, body); err != nil { + r.problem("read the file %s: %v", rel, err) + return + } + got := hex.EncodeToString(sum.Sum(nil)) + if want == "" { + r.problem("the archive holds the file %s, which the manifest does not name", rel) + return + } + if got != want { + r.problem("the file %s is damaged; its checksum is %s and the manifest says %s", rel, short(got), short(want)) + } +} + +func checkMigrations(r *Report, m Manifest) { + carried := map[string]bool{} + for _, name := range migrate.Names() { + carried[name] = true + } + var unknown []string + for _, name := range m.Migrations { + if !carried[name] { + unknown = append(unknown, name) + } + } + if len(unknown) > 0 { + maker := "the pwikit that made it" + if m.Pwikit != "" { + maker = "pwikit " + m.Pwikit + " or a newer release" + } + r.problem("the backup was made by a newer pwikit; it carries %s, which this build does not know. Restore it with %s", + strings.Join(unknown, ", "), maker) + } + if m.PGVersion != 0 && m.PGVersion < MinimumPGVersion { + r.note("the backup came from postgres %s, which is older than this build was tested against", Describe(m.PGVersion)) + } + if !m.Files.Included { + r.note("this backup holds no uploaded files") + } +} + +// A COPY line always ends in a newline and every newline inside a value is +// escaped, so the lines are the rows. +func countRows(r io.Reader) (rows, size int64, err error) { + buf := make([]byte, 64*1024) + for { + n, err := r.Read(buf) + if n > 0 { + size += int64(n) + rows += int64(bytes.Count(buf[:n], []byte{'\n'})) + } + if err == io.EOF { + return rows, size, nil + } + if err != nil { + return rows, size, err + } + } +} + +func ReadManifestOf(name string) (Manifest, error) { + file, err := os.Open(name) + if err != nil { + return Manifest{}, err + } + defer file.Close() + + zip, err := gzip.NewReader(file) + if err != nil { + return Manifest{}, fmt.Errorf("%s is not gzip: %w", name, err) + } + defer zip.Close() + + tr := tar.NewReader(zip) + for { + head, err := tr.Next() + if err == io.EOF { + return Manifest{}, fmt.Errorf("%s holds no %s", name, ManifestName) + } + if err != nil { + return Manifest{}, fmt.Errorf("read %s: %w", name, err) + } + if head.Name == ManifestName { + return readManifest(tr) + } + } +} + +func short(sum string) string { + if len(sum) > 12 { + return sum[:12] + } + return sum +} diff --git a/internal/callbacks/callbacks.go b/internal/callbacks/callbacks.go new file mode 100644 index 00000000..2662120c --- /dev/null +++ b/internal/callbacks/callbacks.go @@ -0,0 +1,235 @@ +package callbacks + +import ( + _ "embed" + "encoding/json" + "errors" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/escape" + "github.com/WikitTeam/ProjectWikit/internal/expr" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/module" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/wikidot" +) + +//go:embed injected.js +var injectedCode string + +const MaxIncludeLevel = 25 + +var ( + ErrUserNotFound = errors.New("user not found") + ErrNoRepository = errors.New("no repository configured") +) + +type ModuleError struct{ Message string } + +func (e *ModuleError) Error() string { return e.Message } + +type Repository interface { + RenderModule(pc *page.Context, name string, params map[string]string, body string) (string, error) + RenderUser(username string, avatar bool) (string, error) + PageInfo(refs []string) ([]renderer.PartialPageInfo, error) + + // IncludeSources answers one page per ref, in the order it was asked. + IncludeSources(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) +} + +type Callbacks struct { + loc *i18n.Localizer + repo Repository + site string + vars *page.Vars + pageCtx *page.Context + level int + includeErrors map[string]bool +} + +var _ renderer.Callbacks = (*Callbacks)(nil) + +// SetPageVars names the page whose %%this|x%% an included page reaches. Without +// it every such name is left standing. +func (c *Callbacks) SetPageVars(vars *page.Vars) { c.vars = vars } + +func (c *Callbacks) SetSite(slug string) { c.site = slug } + +// SetContext hands the modules the page they are rendering into, which is what +// lets one of them redirect the whole request or set the description. +func (c *Callbacks) SetContext(pc *page.Context) { c.pageCtx = pc } + +func New(loc *i18n.Localizer, repo Repository) *Callbacks { + return &Callbacks{ + loc: loc, + repo: repo, + level: MaxIncludeLevel, + includeErrors: make(map[string]bool), + } +} + +func (c *Callbacks) ModuleHasBody(name string) (bool, error) { + return module.HasContent(name), nil +} + +func (c *Callbacks) ModuleIsInline(name string) (bool, error) { + return module.IsInline(name), nil +} + +func (c *Callbacks) RenderModule(name string, params map[string]string, body string) (string, error) { + if c.repo == nil { + return "", ErrNoRepository + } + lowered := make(map[string]string, len(params)) + for key, value := range params { + lowered[strings.ToLower(key)] = value + } + html, err := c.repo.RenderModule(c.pageCtx, name, lowered, body) + var moduleErr *ModuleError + if errors.As(err, &moduleErr) { + return `

      ` + escape.HTML(moduleErr.Message) + `

      `, nil + } + if err != nil { + return "", err + } + return html, nil +} + +func (c *Callbacks) RenderUser(username string, avatar bool) (string, error) { + if c.repo == nil { + return "", ErrNoRepository + } + html, err := c.repo.RenderUser(username, avatar) + if errors.Is(err, ErrUserNotFound) { + return `` + c.text("user-not-found", "name", escape.HTML(username)) + ``, nil + } + if err != nil { + return "", err + } + return html, nil +} + +func (c *Callbacks) GetI18nMessage(id string) (string, error) { + return c.text(id), nil +} + +func (c *Callbacks) GetHTMLInjectedCode(id string) (string, error) { + encoded, err := json.Marshal(id) + if err != nil { + return "", err + } + return strings.Replace(injectedCode, "%s", string(encoded), 1), nil +} + +func (c *Callbacks) GetPageInfo(refs []string) ([]renderer.PartialPageInfo, error) { + if c.repo == nil { + return nil, ErrNoRepository + } + return c.repo.PageInfo(refs) +} + +func (c *Callbacks) EvaluateExpression(source string) (renderer.ExpressionResult, error) { + v := expr.Evaluate(source) + switch v.Kind { + case expr.KindFloat: + return renderer.FloatExpr(v.Float), nil + case expr.KindInt, expr.KindBool: + return renderer.IntExpr(v.AsInt()), nil + case expr.KindStr: + return renderer.StringExpr(v.Str), nil + } + return renderer.ExpressionResult{}, nil +} + +func (c *Callbacks) NormalizePageName(fullName string) (string, error) { + return wikidot.Normalize(fullName), nil +} + +func (c *Callbacks) IncludePages(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) { + if c.level <= 0 { + out := make([]renderer.FetchedPage, 0, len(refs)) + for _, ref := range refs { + name, _ := c.localName(ref.FullName) + c.includeErrors[wikidot.Normalize(name)] = true + out = append(out, renderer.FetchedPage{FullName: ref.FullName}) + } + return out, nil + } + if c.repo == nil { + return nil, ErrNoRepository + } + + local := make([]renderer.IncludeRef, 0, len(refs)) + asked := make([]string, 0, len(refs)) + for _, ref := range refs { + name, _ := c.localName(ref.FullName) + asked = append(asked, ref.FullName) + ref.FullName = name + local = append(local, ref) + } + fetched, err := c.repo.IncludeSources(local) + if err != nil { + return nil, err + } + // ftml pairs the answer with the request by name, so a ref that named this + // wiki has to go back carrying the prefix it arrived with. + for i := range fetched { + if i < len(asked) { + fetched[i].FullName = asked[i] + } + } + // %%this|x%% in an included page names the including page, so the pass runs + // here, on the way in, rather than wherever the include came from. + for i := range fetched { + if fetched[i].Content == nil { + continue + } + substituted := page.PreRender(*fetched[i].Content, c.vars) + fetched[i].Content = &substituted + } + return fetched, nil +} + +// A ref naming this very wiki means the same as one naming no wiki. One naming +// another keeps its prefix, because only the repository can say whether that +// wiki exists and whether the reader may read there. +func (c *Callbacks) localName(fullName string) (string, bool) { + slug, name := wikidot.SplitSiteRef(fullName) + if slug == "" { + return fullName, true + } + if c.site != "" && strings.EqualFold(slug, c.site) { + return name, true + } + return fullName, false +} + +// A ref that named another wiki gets one answer for every way it can fail, so +// the block cannot be used to tell a missing page from an unreadable one. +func (c *Callbacks) NoSuchInclude(fullName string) (string, error) { + name, ok := c.localName(fullName) + if !ok { + return `[[div class="error-block"]]` + c.text("include-off-site", "name", fullName) + `[[/div]]`, nil + } + if c.includeErrors[wikidot.Normalize(name)] { + return `[[div class="error-block"]]` + c.text("include-loop", "name", name) + `[[/div]]`, nil + } + return `[[div class="error-block"]]` + c.text("include-missing", "name", name) + + ` ([[a href="/` + name + `/edit/true" target="_blank"]]` + c.text("include-create") + `[[/a]])[[/div]]`, nil +} + +func (c *Callbacks) NextIncludeLevel() (bool, error) { + if c.level <= 0 { + return false, nil + } + c.level-- + return true, nil +} + +func (c *Callbacks) text(id string, args ...any) string { + if c.loc == nil { + return id + } + return c.loc.T(id, args...) +} diff --git a/internal/callbacks/callbacks_test.go b/internal/callbacks/callbacks_test.go new file mode 100644 index 00000000..05658546 --- /dev/null +++ b/internal/callbacks/callbacks_test.go @@ -0,0 +1,482 @@ +package callbacks + +import ( + "errors" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/renderer" +) + +type fakeRepo struct { + moduleErr error + userErr error + + moduleName string + moduleParam map[string]string + includeSeen []renderer.IncludeRef + includeBody string +} + +func (r *fakeRepo) RenderModule(_ *page.Context, name string, params map[string]string, body string) (string, error) { + r.moduleName = name + r.moduleParam = params + if r.moduleErr != nil { + return "", r.moduleErr + } + return "
      " + name + "
      ", nil +} + +func (r *fakeRepo) RenderUser(username string, avatar bool) (string, error) { + if r.userErr != nil { + return "", r.userErr + } + return "" + username + "", nil +} + +func (r *fakeRepo) PageInfo(refs []string) ([]renderer.PartialPageInfo, error) { + out := make([]renderer.PartialPageInfo, 0, len(refs)) + for _, ref := range refs { + out = append(out, renderer.PartialPageInfo{FullName: ref, Exists: true}) + } + return out, nil +} + +func (r *fakeRepo) IncludeSources(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) { + r.includeSeen = refs + out := make([]renderer.FetchedPage, 0, len(refs)) + for _, ref := range refs { + body := r.includeBody + if body == "" { + body = "内容" + } + out = append(out, renderer.FetchedPage{FullName: ref.FullName, Content: &body}) + } + return out, nil +} + +func newCallbacks(t *testing.T, repo Repository) *Callbacks { + t.Helper() + bundle, err := i18n.Load("") + if err != nil { + t.Fatalf("i18n.Load() err = %v, want nil", err) + } + return New(bundle.Localizer(i18n.DefaultLanguage), repo) +} + +func TestModuleHasBody(t *testing.T) { + c := newCallbacks(t, nil) + tests := []struct { + name string + want bool + }{ + {"css", true}, + {"CSS", true}, + {"rate", false}, + {"interwiki", false}, + {"nosuchmodule", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := c.ModuleHasBody(tt.name) + if err != nil { + t.Fatalf("ModuleHasBody(%q) err = %v, want nil", tt.name, err) + } + if got != tt.want { + t.Errorf("ModuleHasBody(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestModuleIsInline(t *testing.T) { + c := newCallbacks(t, nil) + tests := []struct { + name string + want bool + }{ + {"button", true}, + {"BUTTON", true}, + {"css", false}, + {"listpages", false}, + {"nosuchmodule", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := c.ModuleIsInline(tt.name) + if err != nil { + t.Fatalf("ModuleIsInline(%q) err = %v, want nil", tt.name, err) + } + if got != tt.want { + t.Errorf("ModuleIsInline(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestRenderModuleLowercasesParamKeys(t *testing.T) { + repo := &fakeRepo{} + c := newCallbacks(t, repo) + + if _, err := c.RenderModule("Rate", map[string]string{"ShowVotes": "yes"}, ""); err != nil { + t.Fatalf("RenderModule() err = %v, want nil", err) + } + if repo.moduleName != "Rate" { + t.Errorf("moduleName = %q, want %q", repo.moduleName, "Rate") + } + if _, ok := repo.moduleParam["showvotes"]; !ok { + t.Errorf("params = %v, want key %q", repo.moduleParam, "showvotes") + } +} + +func TestRenderModuleTurnsModuleErrorIntoErrorBlock(t *testing.T) { + repo := &fakeRepo{moduleErr: &ModuleError{Message: `坏了 &`}} + c := newCallbacks(t, repo) + + got, err := c.RenderModule("rate", nil, "") + if err != nil { + t.Fatalf("RenderModule() err = %v, want nil", err) + } + want := `

      坏了 <b>&

      ` + if got != want { + t.Errorf("RenderModule() = %q, want %q", got, want) + } +} + +func TestRenderModulePropagatesOtherErrors(t *testing.T) { + boom := errors.New("boom") + c := newCallbacks(t, &fakeRepo{moduleErr: boom}) + + if _, err := c.RenderModule("rate", nil, ""); !errors.Is(err, boom) { + t.Errorf("RenderModule() err = %v, want %v", err, boom) + } +} + +func TestRenderUserNotFound(t *testing.T) { + c := newCallbacks(t, &fakeRepo{userErr: ErrUserNotFound}) + + got, err := c.RenderUser("kaku用户 'kaku<shi' 不存在` + if got != want { + t.Errorf("RenderUser() = %q, want %q", got, want) + } +} + +func TestGetI18nMessage(t *testing.T) { + c := newCallbacks(t, nil) + tests := []struct { + id string + want string + }{ + {"button-copy-clipboard", "复制"}, + {"toc-open", "展开"}, + {"no-such-message", "no-such-message"}, + } + for _, tt := range tests { + t.Run(tt.id, func(t *testing.T) { + got, err := c.GetI18nMessage(tt.id) + if err != nil { + t.Fatalf("GetI18nMessage(%q) err = %v, want nil", tt.id, err) + } + if got != tt.want { + t.Errorf("GetI18nMessage(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestGetHTMLInjectedCodeSubstitutesIDOnce(t *testing.T) { + c := newCallbacks(t, nil) + + got, err := c.GetHTMLInjectedCode("abc-12") + if err != nil { + t.Fatalf("GetHTMLInjectedCode() err = %v, want nil", err) + } + if !strings.Contains(got, `id: "abc-12"`) { + t.Errorf("output has no %q", `id: "abc-12"`) + } + if strings.Contains(got, "%s") { + t.Errorf("output still contains placeholder %q, want it substituted", "%s") + } + if !strings.HasPrefix(got, "\n + + \ No newline at end of file diff --git a/internal/callbacks/integration_test.go b/internal/callbacks/integration_test.go new file mode 100644 index 00000000..19a502d1 --- /dev/null +++ b/internal/callbacks/integration_test.go @@ -0,0 +1,133 @@ +package callbacks + +import ( + "context" + "github.com/WikitTeam/ProjectWikit/internal/page" + "os" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/renderer/sidecar" +) + +type siteRepo struct { + existing map[string]bool +} + +func (r siteRepo) RenderModule(_ *page.Context, name string, params map[string]string, body string) (string, error) { + return `
      ` + name + "|" + body + `
      `, nil +} + +func (r siteRepo) RenderUser(username string, avatar bool) (string, error) { + if username != "kakushi" { + return "", ErrUserNotFound + } + return `kakushi`, nil +} + +func (r siteRepo) PageInfo(refs []string) ([]renderer.PartialPageInfo, error) { + var out []renderer.PartialPageInfo + for _, ref := range refs { + if r.existing[ref] { + title := ref + out = append(out, renderer.PartialPageInfo{FullName: ref, Exists: true, Title: &title}) + } + } + return out, nil +} + +func (r siteRepo) IncludeSources(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) { + out := make([]renderer.FetchedPage, 0, len(refs)) + for _, ref := range refs { + page := renderer.FetchedPage{FullName: ref.FullName} + if r.existing[ref.FullName] { + body := "**被包含的内容**" + page.Content = &body + } + out = append(out, page) + } + return out, nil +} + +func renderWith(t *testing.T, source string) string { + t.Helper() + binary := os.Getenv(sidecar.EnvBinary) + if binary == "" { + t.Skipf("%s not set, skipping the real render chain test", sidecar.EnvBinary) + } + r, err := sidecar.New(binary) + if err != nil { + t.Fatalf("sidecar.New(%q) err = %v, want nil", binary, err) + } + t.Cleanup(func() { r.Close() }) + + bundle, err := i18n.Load("") + if err != nil { + t.Fatalf("i18n.Load() err = %v, want nil", err) + } + cb := New(bundle.Localizer(i18n.DefaultLanguage), siteRepo{existing: map[string]bool{"exists": true}}) + + info := renderer.PageInfo{Page: "173", Category: "scp", Domain: "example.org"} + got, err := r.RenderHTML(context.Background(), source, info, cb, renderer.ModeArticle) + if err != nil { + t.Fatalf("RenderHTML(%q) err = %v, want nil", source, err) + } + return got.Body +} + +func TestRealRenderUsesCallbacks(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {"module", "[[module Rate]]", `
      Rate|
      `}, + {"module with a body", "[[module CSS]]div{}[[/module]]", `
      CSS|div{}
      `}, + {"red link", "[[[missing|红]]]", `class="newpage"`}, + {"existing link is not a red link", "[[[exists|蓝]]]", `href="/exists"`}, + {"user", "[[user kakushi]]", `kakushi`}, + {"user not found", "[[user nobody]]", "用户 'nobody' 不存在"}, + {"include miss", "[[include :other:page]]", "不存在"}, + {"include hit", "[[include exists]]", "被包含的内容"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := renderWith(t, tt.source) + if !strings.Contains(got, tt.want) { + t.Errorf("RenderHTML(%q) = %q, want substring %q", tt.source, got, tt.want) + } + }) + } +} + +func TestRealRenderUsesI18nCatalog(t *testing.T) { + got := renderWith(t, "正文[[footnote]]脚注内容[[/footnote]]") + if !strings.Contains(got, "脚注") { + t.Errorf("RenderHTML() = %q, want substring %q", got, "脚注") + } +} + +func TestRealRenderMatchesDjangoExpressionOutput(t *testing.T) { + tests := []struct { + expr string + want string + }{ + {"1 == 1", "

      1

      "}, + {"1 == 2", "

      0

      "}, + {"1 + 1", "

      2

      "}, + {"2 ^ 3", "

      1

      "}, + {"sin(0)", "

      "}, + {"round(2.5)", "

      2

      "}, + {"'a' or 'b'", "

      1

      "}, + } + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + got := strings.TrimSpace(renderWith(t, "[[#expr "+tt.expr+"]]")) + if got != tt.want { + t.Errorf("[[#expr %s]] = %q, want %q", tt.expr, got, tt.want) + } + }) + } +} diff --git a/internal/callbacks/testdata/callback_trace.golden b/internal/callbacks/testdata/callback_trace.golden new file mode 100644 index 00000000..d8207088 --- /dev/null +++ b/internal/callbacks/testdata/callback_trace.golden @@ -0,0 +1,89 @@ +== plain == +next_include_level() +include_pages([]) +get_page_info([]) + +== links == +next_include_level() +include_pages([]) +normalize_page_name("exists") +normalize_page_name("missing") +get_page_info([exists missing]) + +== module-plain == +next_include_level() +include_pages([]) +module_has_body(Rate) +module_is_inline(Rate) +get_page_info([]) +render_module(Rate, {}, body="") + +== module-params == +next_include_level() +include_pages([]) +module_has_body(Rate) +module_is_inline(Rate) +get_page_info([]) +render_module(Rate, {limit="5", showVotes="true"}, body="") + +== module-needs-body-inline == +next_include_level() +include_pages([]) +module_has_body(ListPages) +get_page_info([]) + +== module-body == +next_include_level() +include_pages([]) +module_has_body(ListPages) +module_is_inline(ListPages) +get_page_info([]) +render_module(ListPages, {}, body="%%title%%") + +== include-hit == +next_include_level() +include_pages([exists{a="1", b="2"}]) +next_include_level() +include_pages([]) +get_page_info([]) + +== include-miss == +next_include_level() +include_pages([no-such-page{}]) +no_such_include(no-such-page) +next_include_level() +include_pages([]) +get_page_info([]) + +== user == +next_include_level() +include_pages([]) +get_page_info([]) +render_user(kakushi, avatar=false) +render_user(nobody, avatar=true) + +== expression == +next_include_level() +include_pages([]) +evaluate_expression("1 + 1") +get_page_info([]) + +== footnote == +next_include_level() +include_pages([]) +get_page_info([]) +get_i18n_message(footnote-block-title) + +== toc == +next_include_level() +include_pages([]) +get_page_info([]) +get_i18n_message(toc-close) +get_i18n_message(toc-open) +get_i18n_message(table-of-contents) + +== code == +next_include_level() +include_pages([]) +get_page_info([]) + diff --git a/internal/callbacks/testdata/trace_corpus.json b/internal/callbacks/testdata/trace_corpus.json new file mode 100644 index 00000000..d8944eaf --- /dev/null +++ b/internal/callbacks/testdata/trace_corpus.json @@ -0,0 +1,54 @@ +[ + [ + "plain", + "//斜体// 和 **粗体**" + ], + [ + "links", + "[[[exists|蓝]]] 和 [[[missing|红]]]" + ], + [ + "module-plain", + "[[module Rate]]" + ], + [ + "module-params", + "[[module Rate showVotes=\"true\" limit=\"5\"]]" + ], + [ + "module-needs-body-inline", + "[[module ListPages perPage=\"20\" category=\"scp\"]]" + ], + [ + "module-body", + "[[module ListPages]]\n%%title%%\n[[/module]]" + ], + [ + "include-hit", + "[[include exists |a=1 |b=2]]" + ], + [ + "include-miss", + "[[include no-such-page]]" + ], + [ + "user", + "[[user kakushi]] 和 [[*user nobody]]" + ], + [ + "expression", + "[[#expr 1 + 1]]" + ], + [ + "footnote", + "文本[[footnote]]注[[/footnote]]" + ], + [ + "toc", + "[[toc]]\n\n+ 标题" + ], + [ + "code", + "[[code type=\"python\"]]\nprint(1)\n[[/code]]" + ] +] \ No newline at end of file diff --git a/internal/callbacks/trace_test.go b/internal/callbacks/trace_test.go new file mode 100644 index 00000000..922cd6bc --- /dev/null +++ b/internal/callbacks/trace_test.go @@ -0,0 +1,194 @@ +package callbacks + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/renderer/sidecar" +) + +var updateGolden = flag.Bool("update", false, "rewrite the callback trace golden file") + +type tracer struct { + inner renderer.Callbacks + lines []string +} + +func (t *tracer) log(format string, args ...any) { + t.lines = append(t.lines, fmt.Sprintf(format, args...)) +} + +func (t *tracer) ModuleHasBody(name string) (bool, error) { + t.log("module_has_body(%s)", name) + return t.inner.ModuleHasBody(name) +} + +func (t *tracer) ModuleIsInline(name string) (bool, error) { + t.log("module_is_inline(%s)", name) + return t.inner.ModuleIsInline(name) +} + +func (t *tracer) RenderModule(name string, params map[string]string, body string) (string, error) { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%q", k, params[k])) + } + t.log("render_module(%s, {%s}, body=%q)", name, strings.Join(pairs, ", "), body) + return t.inner.RenderModule(name, params, body) +} + +func (t *tracer) RenderUser(user string, avatar bool) (string, error) { + t.log("render_user(%s, avatar=%t)", user, avatar) + return t.inner.RenderUser(user, avatar) +} + +func (t *tracer) GetI18nMessage(id string) (string, error) { + t.log("get_i18n_message(%s)", id) + return t.inner.GetI18nMessage(id) +} + +func (t *tracer) GetHTMLInjectedCode(id string) (string, error) { + t.log("get_html_injected_code(%s)", id) + return t.inner.GetHTMLInjectedCode(id) +} + +func (t *tracer) GetPageInfo(refs []string) ([]renderer.PartialPageInfo, error) { + t.log("get_page_info([%s])", strings.Join(refs, " ")) + return t.inner.GetPageInfo(refs) +} + +func (t *tracer) EvaluateExpression(expr string) (renderer.ExpressionResult, error) { + t.log("evaluate_expression(%q)", expr) + return t.inner.EvaluateExpression(expr) +} + +func (t *tracer) NormalizePageName(fullName string) (string, error) { + t.log("normalize_page_name(%q)", fullName) + return t.inner.NormalizePageName(fullName) +} + +func (t *tracer) IncludePages(refs []renderer.IncludeRef) ([]renderer.FetchedPage, error) { + parts := make([]string, 0, len(refs)) + for _, ref := range refs { + keys := make([]string, 0, len(ref.Variables)) + for k := range ref.Variables { + keys = append(keys, k) + } + sort.Strings(keys) + vars := make([]string, 0, len(keys)) + for _, k := range keys { + vars = append(vars, fmt.Sprintf("%s=%q", k, ref.Variables[k])) + } + parts = append(parts, fmt.Sprintf("%s{%s}", ref.FullName, strings.Join(vars, ", "))) + } + t.log("include_pages([%s])", strings.Join(parts, " ")) + return t.inner.IncludePages(refs) +} + +func (t *tracer) NoSuchInclude(fullName string) (string, error) { + t.log("no_such_include(%s)", fullName) + return t.inner.NoSuchInclude(fullName) +} + +func (t *tracer) NextIncludeLevel() (bool, error) { + t.log("next_include_level()") + return t.inner.NextIncludeLevel() +} + +var traceCorpus = []struct{ name, source string }{ + {"plain", "//斜体// 和 **粗体**"}, + {"links", "[[[exists|蓝]]] 和 [[[missing|红]]]"}, + {"module-plain", "[[module Rate]]"}, + {"module-params", `[[module Rate showVotes="true" limit="5"]]`}, + {"module-needs-body-inline", `[[module ListPages perPage="20" category="scp"]]`}, + {"module-body", "[[module ListPages]]\n%%title%%\n[[/module]]"}, + {"include-hit", "[[include exists |a=1 |b=2]]"}, + {"include-miss", "[[include no-such-page]]"}, + {"user", "[[user kakushi]] 和 [[*user nobody]]"}, + {"expression", "[[#expr 1 + 1]]"}, + {"footnote", "文本[[footnote]]注[[/footnote]]"}, + {"toc", "[[toc]]\n\n+ 标题"}, + {"code", "[[code type=\"python\"]]\nprint(1)\n[[/code]]"}, +} + +func TestCallbackTrace(t *testing.T) { + binary := os.Getenv(sidecar.EnvBinary) + if binary == "" { + t.Skipf("%s not set, skipping the callback trace test", sidecar.EnvBinary) + } + bundle, err := i18n.Load("") + if err != nil { + t.Fatalf("i18n.Load() err = %v, want nil", err) + } + + var out strings.Builder + for _, c := range traceCorpus { + r, err := sidecar.New(binary) + if err != nil { + t.Fatalf("sidecar.New(%q) err = %v, want nil", binary, err) + } + tr := &tracer{inner: New(bundle.Localizer(i18n.DefaultLanguage), siteRepo{existing: map[string]bool{"exists": true}})} + info := renderer.PageInfo{Page: "173", Category: "scp", Domain: "example.org"} + if _, err := r.RenderHTML(context.Background(), c.source, info, tr, renderer.ModeArticle); err != nil { + r.Close() + t.Fatalf("RenderHTML(%s) err = %v, want nil", c.name, err) + } + r.Close() + + fmt.Fprintf(&out, "== %s ==\n", c.name) + if len(tr.lines) == 0 { + out.WriteString("(no callbacks)\n") + } + for _, line := range tr.lines { + out.WriteString(line) + out.WriteString("\n") + } + out.WriteString("\n") + } + + path := filepath.Join("testdata", "callback_trace.golden") + if *updateGolden { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("MkdirAll(testdata) err = %v, want nil", err) + } + if err := os.WriteFile(path, []byte(out.String()), 0o644); err != nil { + t.Fatalf("WriteFile(%s) err = %v, want nil", path, err) + } + corpus := make([][2]string, 0, len(traceCorpus)) + for _, c := range traceCorpus { + corpus = append(corpus, [2]string{c.name, c.source}) + } + encoded, err := json.MarshalIndent(corpus, "", " ") + if err != nil { + t.Fatalf("Marshal(corpus) err = %v, want nil", err) + } + corpusPath := filepath.Join("testdata", "trace_corpus.json") + if err := os.WriteFile(corpusPath, encoded, 0o644); err != nil { + t.Fatalf("WriteFile(%s) err = %v, want nil", corpusPath, err) + } + t.Logf("wrote %s and %s", path, corpusPath) + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) err = %v, want nil; run go test -update to create it", path, err) + } + if got := out.String(); got != string(want) { + t.Errorf("callback trace differs from %s\n--- got ---\n%s\n--- want ---\n%s", path, got, want) + } +} diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 00000000..e691373a --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,382 @@ +// Package changelog turns one article log entry into the flags and comment a +// reader sees beside it. +package changelog + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/wikinum" +) + +var ErrUnreadable = errors.New("changelog: the log entry cannot be read") + +type Users func(ids []int64) ([]db.User, error) + +type Flag struct { + ID string + Desc string +} + +type Entry struct { + Flags []Flag + Comment string +} + +var flags = map[string]string{ + "source": "S", "title": "T", "name": "R", "tags": "A", "new": "N", + "parent": "M", "file_added": "F", "file_deleted": "F", "file_renamed": "F", + "votes_deleted": "V", "authorship": "C", "wikidot": "W", +} + +func TypeName(loc *i18n.Localizer, t string) (flag, desc string) { + f, ok := flags[t] + if !ok { + return "?", "?" + } + return f, text(loc, "module-sitechanges-type-"+strings.ReplaceAll(t, "_", "-")) +} + +// A revert's flags are the types it undid, which it keeps in meta rather than +// in the type column. +func Of(loc *i18n.Localizer, users Users, change db.SiteChange) (Entry, error) { + m, err := metaOf(change.Meta) + if err != nil { + return Entry{}, err + } + + var entry Entry + if raw, ok := m["subtypes"]; ok { + var subtypes []string + if err := json.Unmarshal(raw, &subtypes); err != nil { + return Entry{}, ErrUnreadable + } + for _, subtype := range subtypes { + id, desc := TypeName(loc, subtype) + entry.Flags = append(entry.Flags, Flag{ID: id, Desc: desc}) + } + } else { + id, desc := TypeName(loc, change.Type) + entry.Flags = append(entry.Flags, Flag{ID: id, Desc: desc}) + } + + if entry.Comment, err = comment(loc, users, change, m); err != nil { + return Entry{}, err + } + return entry, nil +} + +func comment(loc *i18n.Localizer, users Users, change db.SiteChange, m metaMap) (string, error) { + if strings.TrimSpace(change.Comment) != "" { + return change.Comment, nil + } + + switch change.Type { + case "new": + return text(loc, "module-sitechanges-comment-new"), nil + case "title": + prev, title, err := m.two("prev_title", "title") + if err != nil { + return "", err + } + return text(loc, "module-sitechanges-comment-title", "prev", prev, "title", title), nil + case "name": + prev, name, err := m.two("prev_name", "name") + if err != nil { + return "", err + } + return text(loc, "module-sitechanges-comment-name", "prev", prev, "name", name), nil + case "tags": + return tagComment(loc, m) + case "parent": + return parentComment(loc, m) + case "file_added": + name, err := m.str("name") + if err != nil { + return "", err + } + return text(loc, "module-sitechanges-comment-file-added", "name", name), nil + case "file_deleted": + name, err := m.str("name") + if err != nil { + return "", err + } + return text(loc, "module-sitechanges-comment-file-deleted", "name", name), nil + case "file_renamed": + prev, name, err := m.two("prev_name", "name") + if err != nil { + return "", err + } + return text(loc, "module-sitechanges-comment-file-renamed", "prev", prev, "name", name), nil + case "votes_deleted": + return votesComment(loc, m) + case "authorship": + return authorComment(loc, users, m) + case "revert": + rev, err := m.str("rev_number") + if err != nil { + return "", err + } + return text(loc, "module-sitechanges-comment-revert", "rev", rev), nil + } + return "", nil +} + +func tagComment(loc *i18n.Localizer, m metaMap) (string, error) { + added, err := m.names("added_tags") + if err != nil { + return "", err + } + removed, err := m.names("removed_tags") + if err != nil { + return "", err + } + + var parts []string + if len(added) > 0 { + parts = append(parts, text(loc, "module-sitechanges-comment-tags-added", + "tags", strings.Join(added, ", "))) + } + if len(removed) > 0 { + parts = append(parts, text(loc, "module-sitechanges-comment-tags-removed", + "tags", strings.Join(removed, ", "))) + } + return strings.Join(parts, " "), nil +} + +func parentComment(loc *i18n.Localizer, m metaMap) (string, error) { + prev, parent, err := m.two("prev_parent", "parent") + if err != nil { + return "", err + } + switch { + case m.truthy("prev_parent") && m.truthy("parent"): + return text(loc, "module-sitechanges-comment-parent-changed", "prev", prev, "parent", parent), nil + case m.truthy("prev_parent"): + return text(loc, "module-sitechanges-comment-parent-removed", "prev", prev), nil + case m.truthy("parent"): + return text(loc, "module-sitechanges-comment-parent-set", "parent", parent), nil + } + return "", nil +} + +func votesComment(loc *i18n.Localizer, m metaMap) (string, error) { + mode, err := m.str("rating_mode") + if err != nil { + return "", err + } + votes, err := m.str("votes_count") + if err != nil { + return "", err + } + popularity, err := m.str("popularity") + if err != nil { + return "", err + } + + rating := text(loc, "module-sitechanges-comment-votes-none") + switch mode { + case "updown": + n, err := m.integer("rating") + if err != nil { + return "", err + } + rating = fmt.Sprintf("%+d", n) + case "stars": + f, err := m.float("rating") + if err != nil { + return "", err + } + rating = strconv.FormatFloat(f, 'f', 1, 64) + } + return text(loc, "module-sitechanges-comment-votes", + "rating", rating, "votes", votes, "popularity", popularity), nil +} + +func authorComment(loc *i18n.Localizer, users Users, m metaMap) (string, error) { + label := func(key string) (string, error) { + ids, err := m.ids(key) + if err != nil { + return "", err + } + found, err := users(ids) + if err != nil { + return "", err + } + names := make([]string, 0, len(found)) + for i := range found { + names = append(names, found[i].DisplayLabel()) + } + return strings.Join(names, ", "), nil + } + + added, err := label("added_authors") + if err != nil { + return "", err + } + removed, err := label("removed_authors") + if err != nil { + return "", err + } + + var parts []string + if added != "" { + parts = append(parts, text(loc, "module-sitechanges-comment-authors-added", "names", added)) + } + if removed != "" { + parts = append(parts, text(loc, "module-sitechanges-comment-authors-removed", "names", removed)) + } + return strings.Join(parts, " "), nil +} + +func text(loc *i18n.Localizer, id string, args ...any) string { + if loc == nil { + return id + } + return loc.T(id, args...) +} + +type metaMap map[string]json.RawMessage + +func metaOf(raw []byte) (metaMap, error) { + if len(raw) == 0 { + return metaMap{}, nil + } + var m metaMap + if err := json.Unmarshal(raw, &m); err != nil { + return nil, ErrUnreadable + } + return m, nil +} + +func (m metaMap) str(key string) (string, error) { + raw, ok := m[key] + if !ok { + return "", ErrUnreadable + } + return metaText(raw), nil +} + +func (m metaMap) two(first, second string) (string, string, error) { + a, err := m.str(first) + if err != nil { + return "", "", err + } + b, err := m.str(second) + if err != nil { + return "", "", err + } + return a, b, nil +} + +func (m metaMap) truthy(key string) bool { + switch t := strings.TrimSpace(string(m[key])); t { + case "", "null", "false", "0", "0.0", `""`, "[]", "{}": + return false + } + return true +} + +func (m metaMap) names(key string) ([]string, error) { + raw, ok := m[key] + if !ok { + return nil, nil + } + var items []map[string]json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return nil, ErrUnreadable + } + out := make([]string, 0, len(items)) + for _, item := range items { + name, ok := item["name"] + if !ok { + return nil, ErrUnreadable + } + out = append(out, metaText(name)) + } + return out, nil +} + +func (m metaMap) ids(key string) ([]int64, error) { + raw, ok := m[key] + if !ok { + return nil, nil + } + var ids []int64 + if err := json.Unmarshal(raw, &ids); err != nil { + return nil, ErrUnreadable + } + return ids, nil +} + +func (m metaMap) integer(key string) (int, error) { + raw, ok := m[key] + if !ok { + return 0, ErrUnreadable + } + if quoted, ok := unquoteJSON(raw); ok { + n, err := wikinum.Int(quoted) + if err != nil { + return 0, ErrUnreadable + } + return n, nil + } + f, err := strconv.ParseFloat(strings.TrimSpace(string(raw)), 64) + if err != nil { + return 0, ErrUnreadable + } + return int(f), nil +} + +func (m metaMap) float(key string) (float64, error) { + raw, ok := m[key] + if !ok { + return 0, ErrUnreadable + } + if quoted, ok := unquoteJSON(raw); ok { + f, err := wikinum.Float(quoted) + if err != nil { + return 0, ErrUnreadable + } + return f, nil + } + f, err := strconv.ParseFloat(strings.TrimSpace(string(raw)), 64) + if err != nil { + return 0, ErrUnreadable + } + return f, nil +} + +func unquoteJSON(raw json.RawMessage) (string, bool) { + if !strings.HasPrefix(strings.TrimSpace(string(raw)), `"`) { + return "", false + } + var out string + if err := json.Unmarshal(raw, &out); err != nil { + return "", false + } + return out, true +} + +// The comment lines interpolate whatever the log entry stored, so a value that +// is not a string still has to come out as text. +func metaText(raw json.RawMessage) string { + t := strings.TrimSpace(string(raw)) + switch t { + case "null": + return "None" + case "true": + return "True" + case "false": + return "False" + } + if unquoted, ok := unquoteJSON(raw); ok { + return unquoted + } + return t +} diff --git a/internal/compress/compress.go b/internal/compress/compress.go new file mode 100644 index 00000000..29faae39 --- /dev/null +++ b/internal/compress/compress.go @@ -0,0 +1,136 @@ +// Package compress gzips a response with a deterministic header, so the same +// body twice produces the same bytes. +package compress + +import ( + "bytes" + "compress/gzip" + "crypto/rand" + "math/big" + "net/http" + "regexp" + "strconv" + "strings" +) + +// Below this size the response goes out untouched, Vary included, so that +// header is not a sign compression was considered. +const minSize = 200 + +const ( + maxPadding = 100 + headerSize = 10 + flagName = 0b00001000 + level = 6 +) + +var acceptsGzip = regexp.MustCompile(`\bgzip\b`) + +func New(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := &buffer{header: make(http.Header), status: http.StatusOK} + next.ServeHTTP(buf, r) + + body := buf.body.Bytes() + for name, values := range buf.header { + w.Header()[name] = values + } + + if len(body) < minSize || buf.header.Get("Content-Encoding") != "" { + write(w, buf.status, body) + return + } + patchVary(w.Header()) + if !acceptsGzip.MatchString(r.Header.Get("Accept-Encoding")) { + write(w, buf.status, body) + return + } + packed, err := pack(body) + if err != nil || len(packed) >= len(body) { + write(w, buf.status, body) + return + } + if tag := w.Header().Get("ETag"); strings.HasPrefix(tag, `"`) { + w.Header().Set("ETag", "W/"+tag) + } + w.Header().Set("Content-Encoding", "gzip") + write(w, buf.status, packed) + }) +} + +func write(w http.ResponseWriter, status int, body []byte) { + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(status) + w.Write(body) +} + +func patchVary(h http.Header) { + const name = "Accept-Encoding" + current := h.Get("Vary") + for _, part := range strings.Split(current, ",") { + if strings.EqualFold(strings.TrimSpace(part), name) { + return + } + } + if strings.TrimSpace(current) == "" { + h.Set("Vary", name) + return + } + h.Set("Vary", current+", "+name) +} + +func pack(body []byte) ([]byte, error) { + var out bytes.Buffer + zw, err := gzip.NewWriterLevel(&out, level) + if err != nil { + return nil, err + } + if _, err := zw.Write(body); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return withPadding(out.Bytes()) +} + +// withPadding hides a run of filler in the header. Only its length is random, +// which is all it takes to stop a compressed length from measuring the page. +func withPadding(packed []byte) ([]byte, error) { + if len(packed) < headerSize { + return packed, nil + } + size, err := rand.Int(rand.Reader, big.NewInt(maxPadding)) + if err != nil { + return nil, err + } + + out := make([]byte, 0, len(packed)+int(size.Int64())+1) + out = append(out, packed[:3]...) + out = append(out, flagName) + out = append(out, packed[4:headerSize]...) + out = append(out, bytes.Repeat([]byte("a"), int(size.Int64()))...) + out = append(out, 0) + return append(out, packed[headerSize:]...), nil +} + +type buffer struct { + header http.Header + status int + body bytes.Buffer + wrote bool +} + +func (b *buffer) Header() http.Header { return b.header } + +func (b *buffer) WriteHeader(status int) { + if !b.wrote { + b.status = status + b.wrote = true + } +} + +func (b *buffer) Write(p []byte) (int, error) { + b.wrote = true + return b.body.Write(p) +} diff --git a/internal/compress/compress_test.go b/internal/compress/compress_test.go new file mode 100644 index 00000000..51e7c5a6 --- /dev/null +++ b/internal/compress/compress_test.go @@ -0,0 +1,169 @@ +package compress + +import ( + "compress/gzip" + "crypto/rand" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func handlerOf(body string, headers map[string]string, status int) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for name, value := range headers { + w.Header().Set(name, value) + } + w.WriteHeader(status) + w.Write([]byte(body)) + }) +} + +func serve(t *testing.T, next http.Handler, accept string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/main", nil) + if accept != "" { + req.Header.Set("Accept-Encoding", accept) + } + rec := httptest.NewRecorder() + New(next).ServeHTTP(rec, req) + return rec.Result() +} + +func unpack(t *testing.T, res *http.Response) string { + t.Helper() + zr, err := gzip.NewReader(res.Body) + if err != nil { + t.Fatalf("gzip.NewReader() err = %v, want nil", err) + } + out, err := io.ReadAll(zr) + if err != nil { + t.Fatalf("ReadAll() err = %v, want nil", err) + } + return string(out) +} + +func TestCompressesALongBody(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, nil, http.StatusOK), "gzip, deflate") + + if got := res.Header.Get("Content-Encoding"); got != "gzip" { + t.Errorf("Content-Encoding = %q, want %q", got, "gzip") + } + if got := unpack(t, res); got != body { + t.Errorf("body = %q, want %q", got, body) + } +} + +func TestContentLengthCountsTheCompressedBody(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, nil, http.StatusOK), "gzip") + + length, err := strconv.Atoi(res.Header.Get("Content-Length")) + if err != nil { + t.Fatalf("Atoi(Content-Length) err = %v, want nil", err) + } + if length >= len(body) { + t.Errorf("Content-Length = %d, want less than %d", length, len(body)) + } +} + +func TestShortBodyIsLeftAlone(t *testing.T) { + res := serve(t, handlerOf("short", nil, http.StatusOK), "gzip") + + if got := res.Header.Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want %q", got, "") + } + if got := res.Header.Get("Vary"); got != "" { + t.Errorf("Vary = %q, want %q", got, "") + } +} + +func TestVaryIsSetWithoutCompressing(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, nil, http.StatusOK), "deflate") + + if got := res.Header.Get("Vary"); got != "Accept-Encoding" { + t.Errorf("Vary = %q, want %q", got, "Accept-Encoding") + } + if got := res.Header.Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want %q", got, "") + } +} + +func TestVaryKeepsWhatTheHandlerSet(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, map[string]string{"Vary": "Cookie"}, http.StatusOK), "gzip") + + if got := res.Header.Get("Vary"); got != "Cookie, Accept-Encoding" { + t.Errorf("Vary = %q, want %q", got, "Cookie, Accept-Encoding") + } +} + +func TestVaryIsNotRepeated(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, map[string]string{"Vary": "Cookie, accept-encoding"}, http.StatusOK), "gzip") + + if got := res.Header.Get("Vary"); got != "Cookie, accept-encoding" { + t.Errorf("Vary = %q, want %q", got, "Cookie, accept-encoding") + } +} + +func TestAlreadyEncodedBodyIsLeftAlone(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, map[string]string{"Content-Encoding": "br"}, http.StatusOK), "gzip") + + if got := res.Header.Get("Content-Encoding"); got != "br" { + t.Errorf("Content-Encoding = %q, want %q", got, "br") + } + if got := res.Header.Get("Vary"); got != "" { + t.Errorf("Vary = %q, want %q", got, "") + } +} + +func TestIncompressibleBodyIsLeftAlone(t *testing.T) { + raw := make([]byte, 512) + if _, err := rand.Read(raw); err != nil { + t.Fatalf("rand.Read() err = %v, want nil", err) + } + res := serve(t, handlerOf(string(raw), nil, http.StatusOK), "gzip") + + if got := res.Header.Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want %q", got, "") + } + if got := res.Header.Get("Vary"); got != "Accept-Encoding" { + t.Errorf("Vary = %q, want %q", got, "Accept-Encoding") + } +} + +func TestStrongETagBecomesWeak(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + res := serve(t, handlerOf(body, map[string]string{"ETag": `"abc"`}, http.StatusOK), "gzip") + + if got := res.Header.Get("ETag"); got != `W/"abc"` { + t.Errorf("ETag = %q, want %q", got, `W/"abc"`) + } +} + +func TestStatusIsKept(t *testing.T) { + body := strings.Repeat("missing page ", 100) + res := serve(t, handlerOf(body, nil, http.StatusNotFound), "gzip") + + if res.StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want %d", res.StatusCode, http.StatusNotFound) + } +} + +func TestPaddingVariesBetweenResponses(t *testing.T) { + body := strings.Repeat("hello wiki ", 100) + lengths := make(map[string]bool) + for i := 0; i < 20; i++ { + res := serve(t, handlerOf(body, nil, http.StatusOK), "gzip") + lengths[res.Header.Get("Content-Length")] = true + } + if len(lengths) < 2 { + t.Errorf("len(distinct Content-Length over 20 responses) = %d, want more than 1", len(lengths)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..5d80ffab --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,172 @@ +// Package config answers what a person wrote into pwikit.toml. +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "runtime" + "sort" + "strings" + + "github.com/BurntSushi/toml" +) + +type File struct { + Database string `toml:"database"` + Server Server `toml:"server"` + TLS TLS `toml:"tls"` + Mail Mail `toml:"mail"` + Analytics Analytics `toml:"analytics"` + Update Update `toml:"update"` +} + +type Update struct { + Auto *bool `toml:"auto"` + PublicBanner *bool `toml:"public_banner"` + Check *bool `toml:"check"` + Window string `toml:"window"` + MinAge string `toml:"min_age"` + Mirror string `toml:"mirror"` +} + +type Server struct { + Listen string `toml:"listen"` + TrustedProxies []string `toml:"trusted_proxies"` + UploadLimit string `toml:"upload_limit"` + StorageLimit string `toml:"storage_limit"` +} + +type TLS struct { + Mode string `toml:"mode"` + Listen string `toml:"listen"` + Cert string `toml:"cert"` + Key string `toml:"key"` + ACMEEmail string `toml:"acme_email"` + ACMEDirectory string `toml:"acme_directory"` +} + +type Mail struct { + Engine string `toml:"engine"` + Host string `toml:"host"` + Port int `toml:"port"` + Username string `toml:"username"` + Password string `toml:"password"` + UseTLS *bool `toml:"use_tls"` + ImplicitTLS *bool `toml:"implicit_tls"` + From string `toml:"from"` +} + +type Analytics struct { + GoogleTagID string `toml:"google_tag_id"` +} + +const ( + EngineSMTP = "smtp" + EngineConsole = "console" +) + +func Load(path string) (File, error) { + var f File + meta, err := toml.DecodeFile(path, &f) + if errors.Is(err, fs.ErrNotExist) { + return File{}, nil + } + if err != nil { + return File{}, fmt.Errorf("read %s: %w", path, err) + } + if unknown := meta.Undecoded(); len(unknown) > 0 { + keys := make([]string, len(unknown)) + for i, key := range unknown { + keys[i] = key.String() + } + sort.Strings(keys) + return File{}, fmt.Errorf("%s has settings pwikit does not know: %s", path, strings.Join(keys, ", ")) + } + if e := f.Mail.Engine; e != "" && e != EngineSMTP && e != EngineConsole { + return File{}, fmt.Errorf("%s sets mail.engine to %q; it takes %q or %q", path, e, EngineSMTP, EngineConsole) + } + return f, nil +} + +func WriteTemplate(path string) (bool, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, fs.ErrExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("write %s: %w", path, err) + } + if _, err := f.WriteString(Template); err != nil { + f.Close() + return false, fmt.Errorf("write %s: %w", path, err) + } + return true, f.Close() +} + +func ReadableByOthers(path string) bool { + if runtime.GOOS == "windows" { + return false + } + info, err := os.Stat(path) + return err == nil && info.Mode().Perm()&0o077 != 0 +} + +const Template = `# Settings for pwikit. A line starting with # is an example and changes nothing +# until the # is taken away. +# A flag on the command line wins over this file, and so does an environment variable. +# Restart pwikit after changing anything here. + +# A PostgreSQL of your own. Left unset, pwikit runs the one it carries. +# database = "postgres://user:password@127.0.0.1:5432/pwikit" + +[server] +# listen = "127.0.0.1:8080" +# trusted_proxies = ["127.0.0.1"] +# upload_limit = "4GB" +# storage_limit = "0" + +[tls] +# Left unset, pwikit serves HTTPS on ports 80 and 443 with certificates from +# Let's Encrypt once a site is bound to a public domain, and plain HTTP on +# 127.0.0.1:8080 until then. Setting listen or trusted_proxies means a proxy sits +# in front, and turns that off. +# off serves plain HTTP, file uses the certificate below, auto always obtains one. +# mode = "auto" +# listen = ":443" +# cert = "/path/to/fullchain.pem" +# key = "/path/to/privkey.pem" +# acme_email = "you@example.com" +# acme_directory = "" + +[mail] +# smtp sends mail, console writes it into the log instead. +# engine = "smtp" +# host = "smtp.example.com" +# port = 587 +# username = "wiki@example.com" +# password = "" +# use_tls = true +# implicit_tls = false +# from = "wiki@example.com" + +[analytics] +# google_tag_id = "" + +[update] +# pwikit installed as a system service looks for a new release once a day and +# installs it by itself. +# auto = true +# Whether every visitor sees the banner announcing an automatic update, or only +# the people who can open the admin panel. +# public_banner = true +# false stops pwikit from asking for new releases at all. +# check = true +# The hours, in this machine's time zone, in which updates are looked for and installed. +# window = "03:00-05:00" +# How long a release must have been out before it is installed automatically. +# min_age = "24h" +# A mirror to download from when GitHub cannot be reached. Use only a mirror you trust. +# mirror = "" +` diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..19630797 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "pwikit.toml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadMissingFileIsEmpty(t *testing.T) { + got, err := Load(filepath.Join(t.TempDir(), "pwikit.toml")) + if err != nil { + t.Fatalf("Load(missing) err = %v, want nil", err) + } + if got.Database != "" || got.Server.Listen != "" || got.Mail.UseTLS != nil { + t.Errorf("Load(missing) = %+v, want the zero File", got) + } +} + +func TestLoadTemplateIsEmpty(t *testing.T) { + got, err := Load(writeConfig(t, Template)) + if err != nil { + t.Fatalf("Load(Template) err = %v, want nil", err) + } + if got.Database != "" || got.TLS.Mode != "" || got.Mail.Port != 0 || got.Mail.UseTLS != nil { + t.Errorf("Load(Template) = %+v, want the zero File", got) + } +} + +func TestLoadTemplateWithEveryLineUncommented(t *testing.T) { + var lines []string + for _, line := range strings.Split(Template, "\n") { + if strings.HasPrefix(line, "# ") && strings.Contains(line, " = ") { + line = strings.TrimPrefix(line, "# ") + } + lines = append(lines, line) + } + got, err := Load(writeConfig(t, strings.Join(lines, "\n"))) + if err != nil { + t.Fatalf("Load(uncommented Template) err = %v, want nil", err) + } + if got.Server.Listen != "127.0.0.1:8080" { + t.Errorf("Server.Listen = %q, want %q", got.Server.Listen, "127.0.0.1:8080") + } + if len(got.Server.TrustedProxies) != 1 || got.Server.TrustedProxies[0] != "127.0.0.1" { + t.Errorf("Server.TrustedProxies = %q, want [127.0.0.1]", got.Server.TrustedProxies) + } + if got.Mail.Port != 587 { + t.Errorf("Mail.Port = %d, want 587", got.Mail.Port) + } + if got.Mail.UseTLS == nil || !*got.Mail.UseTLS { + t.Errorf("Mail.UseTLS = %v, want true", got.Mail.UseTLS) + } + if got.Mail.ImplicitTLS == nil || *got.Mail.ImplicitTLS { + t.Errorf("Mail.ImplicitTLS = %v, want false", got.Mail.ImplicitTLS) + } + if got.TLS.ACMEEmail != "you@example.com" { + t.Errorf("TLS.ACMEEmail = %q, want %q", got.TLS.ACMEEmail, "you@example.com") + } +} + +func TestLoadRefusesAMisspeltKey(t *testing.T) { + _, err := Load(writeConfig(t, "[tls]\nmod = \"auto\"\n[mail]\nhots = \"x\"\n")) + if err == nil { + t.Fatal("Load(misspelt keys) err = nil, want an error") + } + for _, key := range []string{"tls.mod", "mail.hots"} { + if !strings.Contains(err.Error(), key) { + t.Errorf("Load(misspelt keys) err = %q, want it to name %s", err, key) + } + } +} + +func TestLoadRefusesAnUnknownMailEngine(t *testing.T) { + if _, err := Load(writeConfig(t, "[mail]\nengine = \"sendmail\"\n")); err == nil { + t.Error("Load(engine = sendmail) err = nil, want an error") + } +} + +func TestLoadReportsBrokenSyntax(t *testing.T) { + if _, err := Load(writeConfig(t, "[server\nlisten = \n")); err == nil { + t.Error("Load(broken toml) err = nil, want an error") + } +} + +func TestWriteTemplateLeavesAnExistingFileAlone(t *testing.T) { + path := writeConfig(t, "database = \"mine\"\n") + wrote, err := WriteTemplate(path) + if err != nil { + t.Fatalf("WriteTemplate(existing) err = %v, want nil", err) + } + if wrote { + t.Error("WriteTemplate(existing) = true, want false") + } + raw, _ := os.ReadFile(path) + if string(raw) != "database = \"mine\"\n" { + t.Errorf("file after WriteTemplate = %q, want it unchanged", raw) + } +} + +func TestWriteTemplateCreatesAPrivateFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "pwikit.toml") + wrote, err := WriteTemplate(path) + if err != nil || !wrote { + t.Fatalf("WriteTemplate(missing) = %v, %v, want true, nil", wrote, err) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("template mode = %o, want 600", info.Mode().Perm()) + } + } + if ReadableByOthers(path) { + t.Errorf("ReadableByOthers(template) = true, want false") + } +} diff --git a/internal/csrf/csrf.go b/internal/csrf/csrf.go new file mode 100644 index 00000000..8e1551b5 --- /dev/null +++ b/internal/csrf/csrf.go @@ -0,0 +1,195 @@ +// Package csrf mints the token a form carries and checks the one that comes +// back. +package csrf + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "errors" + "mime" + "net/http" + "net/url" +) + +// CookieName is the name the frontend already reads the token back from, so +// the two have to stay spelled the same. +const CookieName = "pwikit_csrftoken" + +const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +const secretLength = 32 + +func Token(r *http.Request) (token string, isNew bool) { + if cookie, err := r.Cookie(CookieName); err == nil && Valid(cookie.Value) { + return cookie.Value, false + } + return newToken(), true +} + +const CookieMaxAge = 60 * 60 * 24 * 365 + +func Issue(w http.ResponseWriter, r *http.Request) string { + token, isNew := Token(r) + if isNew { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, Value: token, Path: "/", + MaxAge: CookieMaxAge, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode, + }) + } + return token +} + +func Valid(token string) bool { + if len(token) != secretLength && len(token) != 2*secretLength { + return false + } + for i := 0; i < len(token); i++ { + c := token[i] + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') { + return false + } + } + return true +} + +func newToken() string { + buf := make([]byte, secretLength) + rand.Read(buf) + for i, b := range buf { + buf[i] = alphabet[int(b)%len(alphabet)] + } + return string(buf) +} + +const ( + HeaderName = "X-CSRFToken" + FormField = "csrfmiddlewaretoken" + + formMime = "application/x-www-form-urlencoded" + multipartMime = "multipart/form-data" + + // Big enough for the token, which is all this reads. How much of a file + // stays in memory is the handler's own parse to decide. + tokenMemory = 1 << 16 +) + +var ( + ErrNoCookie = errors.New("csrf: the request carries no token cookie") + ErrNoToken = errors.New("csrf: the request carries no token") + ErrBadToken = errors.New("csrf: the token does not match the cookie") + ErrBadOrigin = errors.New("csrf: the origin is not one the site answers for") + ErrNoReferer = errors.New("csrf: a request over TLS carries no referer") + ErrBadReferer = errors.New("csrf: the referer is not one the site answers for") +) + +type exemptKey struct{} + +func Exempt(ctx context.Context) context.Context { + return context.WithValue(ctx, exemptKey{}, true) +} + +func exempt(ctx context.Context) bool { + on, _ := ctx.Value(exemptKey{}).(bool) + return on +} + +// The host the request arrived on is trusted alongside hosts. +func Verify(r *http.Request, hosts []string) error { + if exempt(r.Context()) { + return nil + } + if err := verifyOrigin(r, hosts); err != nil { + return err + } + + cookie, err := r.Cookie(CookieName) + if err != nil || !Valid(cookie.Value) { + return ErrNoCookie + } + sent := sentToken(r) + if sent == "" { + return ErrNoToken + } + if !Valid(sent) { + return ErrBadToken + } + if subtle.ConstantTimeCompare([]byte(unmask(sent)), []byte(unmask(cookie.Value))) != 1 { + return ErrBadToken + } + return nil +} + +// A body that is not a form is left unread, since the handler still has to read +// it and a form parse would take it away. A form's parsed values stay on the +// request, so parsing one here costs the handler nothing. +func sentToken(r *http.Request) string { + kind, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + var parsed bool + if err == nil { + switch kind { + case formMime: + parsed = r.ParseForm() == nil + case multipartMime: + parsed = r.ParseMultipartForm(tokenMemory) == nil + } + } + if parsed { + if token := r.PostFormValue(FormField); token != "" { + return token + } + } + return r.Header.Get(HeaderName) +} + +func verifyOrigin(r *http.Request, hosts []string) error { + if origin := r.Header.Get("Origin"); origin != "" { + for _, host := range append([]string{r.Host}, hosts...) { + if host != "" && (origin == "https://"+host || origin == "http://"+host) { + return nil + } + } + return ErrBadOrigin + } + // A browser sends Origin on every unsafe request, so what follows is only + // for the clients that do not. + if r.TLS == nil { + return nil + } + referer := r.Header.Get("Referer") + if referer == "" { + return ErrNoReferer + } + parsed, err := url.Parse(referer) + if err != nil || parsed.Scheme != "https" { + return ErrBadReferer + } + for _, host := range append([]string{r.Host}, hosts...) { + if host != "" && parsed.Host == host { + return nil + } + } + return ErrBadReferer +} + +func unmask(token string) string { + if len(token) != 2*secretLength { + return token + } + mask, cipher := token[:secretLength], token[secretLength:] + out := make([]byte, secretLength) + for i := 0; i < secretLength; i++ { + out[i] = alphabet[((index(cipher[i])-index(mask[i]))%len(alphabet)+len(alphabet))%len(alphabet)] + } + return string(out) +} + +func index(c byte) int { + switch { + case c >= 'a' && c <= 'z': + return int(c - 'a') + case c >= 'A' && c <= 'Z': + return int(c-'A') + 26 + } + return int(c-'0') + 52 +} diff --git a/internal/csrf/csrf_test.go b/internal/csrf/csrf_test.go new file mode 100644 index 00000000..44ba073f --- /dev/null +++ b/internal/csrf/csrf_test.go @@ -0,0 +1,177 @@ +package csrf + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func requestWithToken(value string) *http.Request { + r, _ := http.NewRequest(http.MethodGet, "/", nil) + if value != "" { + r.AddCookie(&http.Cookie{Name: CookieName, Value: value}) + } + return r +} + +func TestTokenKeepsTheCookie(t *testing.T) { + want := "abcdefghijklmnopqrstuvwxyz012345" + + got, isNew := Token(requestWithToken(want)) + if got != want { + t.Errorf("Token() = %q, want %q", got, want) + } + if isNew { + t.Error("Token() isNew = true, want false") + } +} + +func TestTokenMintsOneWhenTheCookieIsMissing(t *testing.T) { + got, isNew := Token(requestWithToken("")) + if !isNew { + t.Error("Token() isNew = false, want true") + } + if !Valid(got) { + t.Errorf("Token() = %q, want a valid token", got) + } +} + +func TestTokenReplacesAMalformedCookie(t *testing.T) { + for _, bad := range []string{"short", "has-a-dash-in-it-and-is-32-chars", ""} { + got, isNew := Token(requestWithToken(bad)) + if !isNew { + t.Errorf("Token(%q) isNew = false, want true", bad) + } + if !Valid(got) { + t.Errorf("Token(%q) = %q, want a valid token", bad, got) + } + } +} + +func TestValidAcceptsBothLengths(t *testing.T) { + short := "abcdefghijklmnopqrstuvwxyz012345" + if !Valid(short) { + t.Errorf("Valid(%q) = false, want true", short) + } + if long := short + short; !Valid(long) { + t.Errorf("Valid(%q) = false, want true", long) + } +} + +func TestUnmaskAgreesWithTheMaskingItMirrors(t *testing.T) { + cases := map[string]string{ + "Ykik5gyNkKCT3GYdyRzhVy2JhcwLHZhwh9oiXILOzIKpP3mJk7ewzuNAxXYN32CS": "tZg82Cnbp8iGWxyGWqPpO6V1qVCcwdvw", + "k2JBICY6IT5TFrIwg2mBYoVbIZKGLRjzpLeJLJsRP297eenQMqQLRbvqcZ2cFmRB": "fTFidhEVhjeoJXPuGyEk3XKpEasG4FIc", + "EJttOBbCOmSK4PSG360oniSsLI8Qc9kb4z0GIwksQVzDA2lwIg2Sw0kW5lyt149U": "A0Hn45j0cJR3GnD0PkcEjSCEuNANZ5ZT", + "L2rSN5fa8jqm98Jz3OsOFx2CirITybghj3dIpxoymRnEtfdVEZ1a9yLWrrdKvz2w": "IbW0MCjyoI7suhEwLlJwEbTujaF17yWp", + } + for masked, want := range cases { + if got := unmask(masked); got != want { + t.Errorf("unmask(%q) = %q, want %q", masked, got, want) + } + } +} + +func TestUnmaskLeavesABareSecretAlone(t *testing.T) { + const secret = "tZg82Cnbp8iGWxyGWqPpO6V1qVCcwdvw" + if got := unmask(secret); got != secret { + t.Errorf("unmask(%q) = %q, want it unchanged", secret, got) + } +} + +const ( + testSecret = "tZg82Cnbp8iGWxyGWqPpO6V1qVCcwdvw" + testMasked = "Ykik5gyNkKCT3GYdyRzhVy2JhcwLHZhwh9oiXILOzIKpP3mJk7ewzuNAxXYN32CS" +) + +func postWith(cookie, header, origin string) *http.Request { + r, _ := http.NewRequest(http.MethodPost, "http://wiki.test/pw-api/preview", nil) + r.Host = "wiki.test" + if cookie != "" { + r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) + } + if header != "" { + r.Header.Set(HeaderName, header) + } + if origin != "" { + r.Header.Set("Origin", origin) + } + return r +} + +func TestVerifyAcceptsAMatchingToken(t *testing.T) { + cases := map[string]*http.Request{ + "both bare": postWith(testSecret, testSecret, "http://wiki.test"), + "masked header": postWith(testSecret, testMasked, "http://wiki.test"), + "masked cookie": postWith(testMasked, testSecret, "http://wiki.test"), + "both masked": postWith(testMasked, testMasked, "https://wiki.test"), + "no origin": postWith(testSecret, testSecret, ""), + } + for name, r := range cases { + if err := Verify(r, nil); err != nil { + t.Errorf("Verify(%s) err = %v, want nil", name, err) + } + } +} + +func TestVerifyRejects(t *testing.T) { + other := "Ykik5gyNkKCT3GYdyRzhVy2JhcwLHZhwh9oiXILOzIKpP3mJk7ewzuNAxXYN32CT" + cases := map[string]struct { + request *http.Request + want error + }{ + "no cookie": {postWith("", testSecret, ""), ErrNoCookie}, + "malformed cookie": {postWith("short", testSecret, ""), ErrNoCookie}, + "no token": {postWith(testSecret, "", ""), ErrNoToken}, + "malformed token": {postWith(testSecret, "short", ""), ErrBadToken}, + "another secret": {postWith(testSecret, other, ""), ErrBadToken}, + "foreign origin": {postWith(testSecret, testSecret, "https://evil.test"), ErrBadOrigin}, + } + for name, c := range cases { + if err := Verify(c.request, nil); !errors.Is(err, c.want) { + t.Errorf("Verify(%s) err = %v, want %v", name, err, c.want) + } + } +} + +func TestVerifyAcceptsATrustedHost(t *testing.T) { + r := postWith(testSecret, testSecret, "https://media.wiki.test") + + if err := Verify(r, []string{"media.wiki.test"}); err != nil { + t.Errorf("Verify(trusted host) err = %v, want nil", err) + } + if err := Verify(r, nil); !errors.Is(err, ErrBadOrigin) { + t.Errorf("Verify(untrusted host) err = %v, want %v", err, ErrBadOrigin) + } +} + +func TestVerifyReadsAFormField(t *testing.T) { + body := strings.NewReader(FormField + "=" + testMasked + "&other=1") + r, _ := http.NewRequest(http.MethodPost, "http://wiki.test/x", body) + r.Host = "wiki.test" + r.Header.Set("Content-Type", formMime) + r.AddCookie(&http.Cookie{Name: CookieName, Value: testSecret}) + + if err := Verify(r, nil); err != nil { + t.Errorf("Verify(form field) err = %v, want nil", err) + } +} + +func TestVerifyLeavesABodyThatIsNotAFormUnread(t *testing.T) { + body := `{"module": "x"}` + r, _ := http.NewRequest(http.MethodPost, "http://wiki.test/x", strings.NewReader(body)) + r.Host = "wiki.test" + r.Header.Set("Content-Type", "application/json") + r.Header.Set(HeaderName, testSecret) + r.AddCookie(&http.Cookie{Name: CookieName, Value: testSecret}) + + if err := Verify(r, nil); err != nil { + t.Fatalf("Verify(json body) err = %v, want nil", err) + } + read, _ := io.ReadAll(r.Body) + if string(read) != body { + t.Errorf("body after Verify = %q, want %q", read, body) + } +} diff --git a/internal/db/account_write.go b/internal/db/account_write.go new file mode 100644 index 00000000..fe47cea4 --- /dev/null +++ b/internal/db/account_write.go @@ -0,0 +1,339 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +var qCreateUser = register("CreateUser", ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, display_name, type, bio, is_forum_active, is_active, can_send_direct_messages, + pending_email, previous_email) +VALUES ($1, false, '', '', '', $2, $3, $4, $5, '', true, $6, true, '', '') +RETURNING id`) + +func (d *DB) CreateUser(ctx context.Context, username, displayName, hash string, active bool, at time.Time) (int64, error) { + return d.CreateTypedUser(ctx, username, displayName, hash, UserTypeNormal, active, at) +} + +func (d *DB) CreateTypedUser(ctx context.Context, username, displayName, hash, kind string, active bool, at time.Time) (int64, error) { + var display *string + if displayName != "" { + display = &displayName + } + var id int64 + err := d.pool.QueryRow(ctx, qCreateUser, hash, at, username, display, kind, active).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create user %q: %w", username, err) + } + return id, nil +} + +var qCreateBot = register("CreateBot", ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, display_name, type, api_key, bio, is_forum_active, is_active, + can_send_direct_messages, pending_email, previous_email) +VALUES ('!', false, '', '', '', $1, $2, NULL, 'bot', $3, '', true, true, true, '', '') +RETURNING id`) + +// A bot signs in with its key rather than a password, so the password column +// holds the marker that makes every comparison fail. +func (d *DB) CreateBot(ctx context.Context, username, apiKey string, at time.Time) (int64, error) { + var id int64 + if err := d.pool.QueryRow(ctx, qCreateBot, at, username, apiKey).Scan(&id); err != nil { + return 0, fmt.Errorf("create bot %q: %w", username, err) + } + return id, nil +} + +var qUsernameTaken = register("UsernameTaken", ` +SELECT EXISTS ( + SELECT 1 FROM web_user WHERE username = $1 OR wikidot_username = $1)`) + +func (d *DB) UsernameTaken(ctx context.Context, name string) (bool, error) { + var taken bool + if err := d.pool.QueryRow(ctx, qUsernameTaken, name).Scan(&taken); err != nil { + return false, fmt.Errorf("check name %q: %w", name, err) + } + return taken, nil +} + +var qRenameUser = register("RenameUser", ` +UPDATE web_user +SET username = $2, display_name = $3 +WHERE id = $1`) + +func (d *DB) RenameUser(ctx context.Context, id int64, username, displayName string) error { + var display *string + if displayName != "" { + display = &displayName + } + if _, err := d.pool.Exec(ctx, qRenameUser, id, username, display); err != nil { + return fmt.Errorf("rename user %d: %w", id, err) + } + return nil +} + +var qActivateUser = register("ActivateUser", ` +UPDATE web_user +SET username = $2, display_name = COALESCE($3, display_name), type = $4, + password = $5, is_active = true +WHERE id = $1`) + +func (d *DB) ActivateUser(ctx context.Context, id int64, username string, displayName *string, hash string) error { + if _, err := d.pool.Exec(ctx, qActivateUser, id, username, displayName, UserTypeNormal, hash); err != nil { + return fmt.Errorf("activate user %d: %w", id, err) + } + return nil +} + +var qGrantRole = register("GrantRole", ` +INSERT INTO web_user_roles (user_id, role_id) +SELECT $1, $2 +WHERE EXISTS (SELECT 1 FROM web_role WHERE id = $2 AND site_id = $3) + AND NOT EXISTS ( + SELECT 1 FROM web_user_roles WHERE user_id = $1 AND role_id = $2)`) + +func (d *DB) GrantRole(ctx context.Context, siteID, userID, roleID int64) error { + if _, err := d.pool.Exec(ctx, qGrantRole, userID, roleID, siteID); err != nil { + return fmt.Errorf("grant role %d to user %d: %w", roleID, userID, err) + } + return nil +} + +var qUserByEmail = register("UserByEmail", ` +SELECT `+userColumns+` +FROM web_user +WHERE lower(email) = lower($1) AND email <> '' +ORDER BY id +LIMIT 1`) + +func (d *DB) UserByEmail(ctx context.Context, email string) (*User, error) { + return d.scanUser(ctx, qUserByEmail, email) +} + +// Only a verified address is trusted with a way into the account. +var qUserByVerifiedEmail = register("UserByVerifiedEmail", ` +SELECT `+userColumns+` +FROM web_user +WHERE lower(email) = lower($1) AND email <> '' AND email_verified_at IS NOT NULL +ORDER BY id +LIMIT 1`) + +func (d *DB) UserByVerifiedEmail(ctx context.Context, email string) (*User, error) { + return d.scanUser(ctx, qUserByVerifiedEmail, email) +} + +var qSetEmail = register("SetEmail", `UPDATE web_user SET email = $2 WHERE id = $1`) + +func (d *DB) SetEmail(ctx context.Context, id int64, email string) error { + if _, err := d.pool.Exec(ctx, qSetEmail, id, email); err != nil { + return fmt.Errorf("store email of user %d: %w", id, err) + } + return nil +} + +var qRoleIDBySlug = register("RoleIDBySlug", `SELECT id FROM web_role WHERE site_id = $1 AND slug = $2`) + +func (d *DB) RoleIDBySlug(ctx context.Context, siteID int64, slug string) (int64, error) { + var id int64 + err := d.pool.QueryRow(ctx, qRoleIDBySlug, siteID, slug).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrNotFound + } + if err != nil { + return 0, fmt.Errorf("look up role %q: %w", slug, err) + } + return id, nil +} + +var qResetFields = register("ResetFields", ` +SELECT password, last_login, email +FROM web_user +WHERE id = $1`) + +func (d *DB) ResetFields(ctx context.Context, id int64) (string, *time.Time, string, error) { + var ( + hash string + last *time.Time + email string + ) + err := d.pool.QueryRow(ctx, qResetFields, id).Scan(&hash, &last, &email) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil, "", ErrNotFound + } + if err != nil { + return "", nil, "", fmt.Errorf("read reset fields of user %d: %w", id, err) + } + return hash, last, email, nil +} + +var qSetUserPreference = register("SetUserPreference", ` +INSERT INTO dynamic_preferences_users_userpreferencemodel (instance_id, section, name, raw_value) +VALUES ($1, $2, $3, $4) +ON CONFLICT (instance_id, section, name) DO UPDATE SET raw_value = EXCLUDED.raw_value`) + +func (d *DB) SetUserPreference(ctx context.Context, userID int64, section, name, raw string) error { + if _, err := d.pool.Exec(ctx, qSetUserPreference, userID, section, name, raw); err != nil { + return fmt.Errorf("store preference %s.%s of user %d: %w", section, name, userID, err) + } + return nil +} + +var qCreateInvitedUser = register("CreateInvitedUser", ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, type, bio, is_forum_active, is_active, can_send_direct_messages, + pending_email, previous_email) +VALUES ('!', false, '', '', $1::text, $2, 'invite-' || md5($1::text), $3, '', true, false, true, '', '') +RETURNING id`) + +var qNameInvitedUser = register("NameInvitedUser", ` +UPDATE web_user SET username = 'user-' || id WHERE id = $1`) + +func (d *DB) CreateInvitedUser(ctx context.Context, email string, at time.Time) (int64, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin invited user: %w", err) + } + defer tx.Rollback(ctx) + + var id int64 + if err := tx.QueryRow(ctx, qCreateInvitedUser, email, at, UserTypeNormal).Scan(&id); err != nil { + return 0, fmt.Errorf("create invited user %q: %w", email, err) + } + if _, err := tx.Exec(ctx, qNameInvitedUser, id); err != nil { + return 0, fmt.Errorf("name invited user %d: %w", id, err) + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit invited user: %w", err) + } + return id, nil +} + +var qTokenUsed = register("TokenUsed", ` +SELECT EXISTS ( + SELECT 1 FROM web_usedtoken + WHERE (is_case_sensitive AND token = $1) + OR (NOT is_case_sensitive AND upper(token) = upper($1)))`) + +func (d *DB) TokenUsed(ctx context.Context, token string) (bool, error) { + var used bool + if err := d.pool.QueryRow(ctx, qTokenUsed, token).Scan(&used); err != nil { + return false, fmt.Errorf("check token: %w", err) + } + return used, nil +} + +var qMarkTokenUsed = register("MarkTokenUsed", ` +INSERT INTO web_usedtoken (token, is_case_sensitive) VALUES ($1, $2)`) + +func (d *DB) MarkTokenUsed(ctx context.Context, token string, caseSensitive bool) error { + if _, err := d.pool.Exec(ctx, qMarkTokenUsed, token, caseSensitive); err != nil { + return fmt.Errorf("mark token used: %w", err) + } + return nil +} + +var qActivateInviteLink = register("ActivateInviteLink", ` +UPDATE web_invitelink +SET activated_at = $2, activated_username = $3 +WHERE token = $1 AND activated_at IS NULL`) + +func (d *DB) ActivateInviteLink(ctx context.Context, token, username string, at time.Time) error { + if _, err := d.pool.Exec(ctx, qActivateInviteLink, token, at, username); err != nil { + return fmt.Errorf("mark invite link used: %w", err) + } + return nil +} + +var qCreateInviteLink = register("CreateInviteLink", ` +INSERT INTO web_invitelink (kind, delivery, email, wikidot_username, token, uidb64, + created_at, activated_username, created_by_id, target_id, site_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, '', $8, $9, $10) +RETURNING id`) + +func (d *DB) CreateInviteLink(ctx context.Context, siteID int64, kind, delivery, email, wikidotName, + token, uid string, createdBy *int64, target int64, at time.Time) (int64, error) { + + var id int64 + err := d.pool.QueryRow(ctx, qCreateInviteLink, kind, delivery, email, wikidotName, + token, uid, at, createdBy, target, siteID).Scan(&id) + if err != nil { + return 0, fmt.Errorf("write invite link: %w", err) + } + return id, nil +} + +const ( + TicketKind = "ticket" + MembershipApplyKind = "membershipapply" + + TicketPending = "pending" +) + +var qCreateTicket = register("CreateTicket", ` +INSERT INTO web_userticket (kind, subject, body, source_page, status, admin_notes, created_at, author_id, site_id) +VALUES ($1, $2, $3, $4, $5, '', $6, $7, $8) +RETURNING id`) + +func (d *DB) CreateTicket(ctx context.Context, siteID int64, kind, subject, body, page string, authorID int64, at time.Time) (int64, error) { + var id int64 + err := d.pool.QueryRow(ctx, qCreateTicket, kind, subject, body, page, TicketPending, at, authorID, siteID).Scan(&id) + if err != nil { + return 0, fmt.Errorf("write ticket by %d: %w", authorID, err) + } + return id, nil +} + +// The name is compared twice because an archive keeps whatever spelling the +// other site displayed, while a local name is already canonical. +var qUserToClaim = register("UserToClaim", ` +SELECT `+userColumns+`, password +FROM web_user +WHERE username = $1 OR lower(wikidot_username) = $1 OR lower(wikidot_username) = $2 +ORDER BY id +LIMIT 1`) + +// The hash comes back because an unusable one is what tells a row waiting to be +// claimed apart from somebody's live account. +func (d *DB) UserToClaim(ctx context.Context, canonical, spelled string) (*User, string, error) { + var ( + u User + hash string + ) + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, qUserToClaim, canonical, spelled).Scan(append(dest, &hash)...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, "", ErrNotFound + } + if err != nil { + return nil, "", fmt.Errorf("look up account %q: %w", canonical, err) + } + finish() + return &u, hash, nil +} + +func (d *DB) SetSuperuser(ctx context.Context, id int64, on bool) error { + tag, err := d.pool.Exec(ctx, qSetSuperuser, id, on) + if err != nil { + return fmt.Errorf("set superuser on %d: %w", id, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +var qSuperuserCount = register("SuperuserCount", `SELECT count(*) FROM web_user WHERE is_superuser`) + +func (d *DB) SuperuserCount(ctx context.Context) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qSuperuserCount).Scan(&n); err != nil { + return 0, fmt.Errorf("count superusers: %w", err) + } + return n, nil +} diff --git a/internal/db/account_write_test.go b/internal/db/account_write_test.go new file mode 100644 index 00000000..f4824d0b --- /dev/null +++ b/internal/db/account_write_test.go @@ -0,0 +1,269 @@ +package db + +import ( + "context" + "errors" + "strconv" + "strings" + "testing" + "time" +) + +func scratchName(t *testing.T) string { + t.Helper() + return "probe-acct-" + time.Now().Format("150405.000000") +} + +func dropUser(t *testing.T, d *DB, id int64) { + t.Helper() + t.Cleanup(func() { + ctx := context.Background() + for _, sql := range []string{ + `DELETE FROM web_usernotificationmapping WHERE recipient_id = $1`, + `DELETE FROM web_user_roles WHERE user_id = $1`, + `DELETE FROM web_userticket WHERE author_id = $1`, + `DELETE FROM web_invitelink WHERE target_id = $1`, + `DELETE FROM web_user WHERE id = $1`, + } { + if _, err := d.pool.Exec(ctx, sql, id); err != nil { + t.Errorf("clean up user %d err = %v, want nil", id, err) + } + } + }) +} + +func TestCreateUser(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + name := scratchName(t) + + id, err := d.CreateUser(ctx, name, "Probe Account", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, id) + + got, err := d.UserByID(ctx, id) + if err != nil { + t.Fatalf("UserByID() err = %v, want nil", err) + } + if got.Username != name { + t.Errorf("UserByID().Username = %q, want %q", got.Username, name) + } + if got.DisplayName != "Probe Account" { + t.Errorf("UserByID().DisplayName = %q, want %q", got.DisplayName, "Probe Account") + } + if !got.IsActive { + t.Errorf("UserByID().IsActive = false, want true") + } + + taken, err := d.UsernameTaken(ctx, name) + if err != nil { + t.Fatalf("UsernameTaken() err = %v, want nil", err) + } + if !taken { + t.Errorf("UsernameTaken(%q) = false, want true", name) + } +} + +func TestCreateInvitedUserIsNamedAfterItsID(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + email := scratchName(t) + "@example.invalid" + + id, err := d.CreateInvitedUser(ctx, email, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateInvitedUser() err = %v, want nil", err) + } + dropUser(t, d, id) + + got, err := d.UserByID(ctx, id) + if err != nil { + t.Fatalf("UserByID() err = %v, want nil", err) + } + if want := "user-" + strconv.FormatInt(id, 10); got.Username != want { + t.Errorf("UserByID().Username = %q, want %q", got.Username, want) + } + if got.IsActive { + t.Errorf("UserByID().IsActive = true, want false") + } + + found, err := d.UserByEmail(ctx, strings.ToUpper(email)) + if err != nil { + t.Fatalf("UserByEmail() err = %v, want nil", err) + } + if found.ID != id { + t.Errorf("UserByEmail().ID = %d, want %d", found.ID, id) + } +} + +func TestActivateUser(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id, err := d.CreateInvitedUser(ctx, scratchName(t)+"@example.invalid", time.Now().UTC()) + if err != nil { + t.Fatalf("CreateInvitedUser() err = %v, want nil", err) + } + dropUser(t, d, id) + + name := scratchName(t) + display := "Probe Display" + if err := d.ActivateUser(ctx, id, name, &display, "hashed"); err != nil { + t.Fatalf("ActivateUser() err = %v, want nil", err) + } + got, hash, err := d.UserForLogin(ctx, name) + if err != nil { + t.Fatalf("UserForLogin() err = %v, want nil", err) + } + if !got.IsActive { + t.Errorf("UserForLogin().IsActive = false, want true") + } + if hash != "hashed" { + t.Errorf("UserForLogin() hash = %q, want %q", hash, "hashed") + } + if got.DisplayName != display { + t.Errorf("UserForLogin().DisplayName = %q, want %q", got.DisplayName, display) + } +} + +func TestActivateUserKeepsTheStoredDisplayName(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id, err := d.CreateUser(ctx, scratchName(t), "Kept Name", "!", false, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, id) + + name := scratchName(t) + "-b" + if err := d.ActivateUser(ctx, id, name, nil, "hashed"); err != nil { + t.Fatalf("ActivateUser() err = %v, want nil", err) + } + got, err := d.UserByID(ctx, id) + if err != nil { + t.Fatalf("UserByID() err = %v, want nil", err) + } + if got.DisplayName != "Kept Name" { + t.Errorf("UserByID().DisplayName = %q, want %q", got.DisplayName, "Kept Name") + } +} + +func TestSetPasswordAndLastLogin(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id, err := d.CreateUser(ctx, scratchName(t), "", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, id) + + if err := d.SetPassword(ctx, id, "fresh"); err != nil { + t.Fatalf("SetPassword() err = %v, want nil", err) + } + when := time.Date(2026, 9, 5, 11, 4, 34, 0, time.UTC) + if err := d.SetLastLogin(ctx, id, when); err != nil { + t.Fatalf("SetLastLogin() err = %v, want nil", err) + } + hash, last, _, err := d.ResetFields(ctx, id) + if err != nil { + t.Fatalf("ResetFields() err = %v, want nil", err) + } + if hash != "fresh" { + t.Errorf("ResetFields() hash = %q, want %q", hash, "fresh") + } + if last == nil || !last.UTC().Equal(when) { + t.Errorf("ResetFields() last login = %v, want %v", last, when) + } +} + +func TestSetUserPreference(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + user := scratchUser(t, d, "probe-pref") + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM dynamic_preferences_users_userpreferencemodel WHERE instance_id = $1`, user); err != nil { + t.Errorf("clean up preference err = %v, want nil", err) + } + }) + + if _, err := d.UserPreference(ctx, user, "qol", "advanced_source_editor_enabled"); !errors.Is(err, ErrNotFound) { + t.Errorf("UserPreference(unset) err = %v, want ErrNotFound", err) + } + for _, want := range []string{"True", "False"} { + if err := d.SetUserPreference(ctx, user, "qol", "advanced_source_editor_enabled", want); err != nil { + t.Fatalf("SetUserPreference(%q) err = %v, want nil", want, err) + } + got, err := d.UserPreference(ctx, user, "qol", "advanced_source_editor_enabled") + if err != nil { + t.Fatalf("UserPreference() err = %v, want nil", err) + } + if got != want { + t.Errorf("UserPreference() = %q, want %q", got, want) + } + } +} + +func TestTokenUsed(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + one := "probe-token-" + time.Now().Format("150405.000000") + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), `DELETE FROM web_usedtoken WHERE token = $1`, one); err != nil { + t.Errorf("clean up token err = %v, want nil", err) + } + }) + + used, err := d.TokenUsed(ctx, one) + if err != nil { + t.Fatalf("TokenUsed() err = %v, want nil", err) + } + if used { + t.Errorf("TokenUsed(fresh) = true, want false") + } + if err := d.MarkTokenUsed(ctx, one, true); err != nil { + t.Fatalf("MarkTokenUsed() err = %v, want nil", err) + } + used, err = d.TokenUsed(ctx, one) + if err != nil { + t.Fatalf("TokenUsed() err = %v, want nil", err) + } + if !used { + t.Errorf("TokenUsed(spent) = false, want true") + } + other, err := d.TokenUsed(ctx, strings.ToUpper(one)) + if err != nil { + t.Fatalf("TokenUsed(uppercased) err = %v, want nil", err) + } + if other { + t.Errorf("TokenUsed(uppercased) = true, want false") + } +} + +func TestCreateTicket(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + author := scratchUser(t, d, "probe-ticket") + + id, err := d.CreateTicket(ctx, seedSiteID(t, d), TicketKind, "subject", "body", "main", author, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateTicket() err = %v, want nil", err) + } + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), `DELETE FROM web_userticket WHERE id = $1`, id); err != nil { + t.Errorf("clean up ticket err = %v, want nil", err) + } + }) + + var kind, status string + err = d.pool.QueryRow(ctx, `SELECT kind, status FROM web_userticket WHERE id = $1`, id).Scan(&kind, &status) + if err != nil { + t.Fatalf("read ticket err = %v, want nil", err) + } + if kind != TicketKind { + t.Errorf("ticket kind = %q, want %q", kind, TicketKind) + } + if status != TicketPending { + t.Errorf("ticket status = %q, want %q", status, TicketPending) + } +} diff --git a/internal/db/address.go b/internal/db/address.go new file mode 100644 index 00000000..0f4397fd --- /dev/null +++ b/internal/db/address.go @@ -0,0 +1,146 @@ +package db + +import ( + "context" + "fmt" + "net/netip" + "time" +) + +// A repeat from the same address inside this window writes nothing, so a busy +// session costs one row and then no further writes. +const addressQuiet = time.Hour + +var qSeenAddress = register("SeenAddress", ` +INSERT INTO pwikit_user_address (user_id, address, first_seen, last_seen) +VALUES ($1, $2, $3, $3) +ON CONFLICT (user_id, address) DO UPDATE +SET last_seen = EXCLUDED.last_seen, hits = pwikit_user_address.hits + 1 +WHERE pwikit_user_address.last_seen < EXCLUDED.last_seen - $4::interval`) + +func (d *DB) SeenAddress(ctx context.Context, userID int64, address *netip.Addr, at time.Time) error { + if address == nil || !address.IsValid() { + return nil + } + _, err := d.pool.Exec(ctx, qSeenAddress, userID, address.String(), at, addressQuiet) + if err != nil { + return fmt.Errorf("record address of user %d: %w", userID, err) + } + return nil +} + +const ( + AdminCreated = "create" + AdminChanged = "change" + AdminDeleted = "delete" +) + +type AdminNote struct { + UserID *int64 + Name string + Action string + Screen string + Target string + Label string + SiteID int64 + At time.Time +} + +var qWriteAdminNote = register("WriteAdminNote", ` +INSERT INTO pwikit_admin_log (user_id, stale_name, action, screen, target, label, created_at, site_id) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`) + +func (d *DB) WriteAdminNote(ctx context.Context, n AdminNote) error { + _, err := d.pool.Exec(ctx, qWriteAdminNote, + n.UserID, n.Name, n.Action, n.Screen, n.Target, n.Label, n.At, n.SiteID) + if err != nil { + return fmt.Errorf("record admin action %q on %q: %w", n.Action, n.Screen, err) + } + return nil +} + +type AdminNoteRow struct { + ID int64 + User string + Stale string + Action string + Screen string + Target string + Label string + CreatedAt time.Time +} + +var qAdminNotes = register("AdminNotes", ` +SELECT l.id, coalesce(u.username, ''), l.stale_name, l.action, l.screen, l.target, l.label, l.created_at +FROM pwikit_admin_log l +LEFT JOIN web_user u ON u.id = l.user_id +WHERE ($1 = '' OR l.screen = $1) AND l.site_id = $4 +ORDER BY l.created_at DESC, l.id DESC +LIMIT $2 OFFSET $3`) + +var qAdminNoteScreens = register("AdminNoteScreens", ` +SELECT DISTINCT screen FROM pwikit_admin_log WHERE site_id = $1 ORDER BY 1`) + +func (d *DB) AdminNotes(ctx context.Context, siteID int64, screen string, limit, offset int) ([]AdminNoteRow, error) { + rows, err := d.pool.Query(ctx, qAdminNotes, screen, limit, offset, siteID) + if err != nil { + return nil, fmt.Errorf("list admin actions: %w", err) + } + defer rows.Close() + + var out []AdminNoteRow + for rows.Next() { + var one AdminNoteRow + if err := rows.Scan(&one.ID, &one.User, &one.Stale, &one.Action, + &one.Screen, &one.Target, &one.Label, &one.CreatedAt); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} + +func (d *DB) AdminNoteScreens(ctx context.Context, siteID int64) ([]string, error) { + rows, err := d.pool.Query(ctx, qAdminNoteScreens, siteID) + if err != nil { + return nil, fmt.Errorf("list admin action screens: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var one string + if err := rows.Scan(&one); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} + +var qClearAddresses = register("ClearAddresses", `DELETE FROM pwikit_user_address`) + +var qClearUserAddresses = register("ClearUserAddresses", ` +DELETE FROM pwikit_user_address WHERE user_id = $1`) + +func (d *DB) ClearAddresses(ctx context.Context, userID *int64) (int64, error) { + sql, args := qClearAddresses, []any{} + if userID != nil { + sql, args = qClearUserAddresses, []any{*userID} + } + tag, err := d.pool.Exec(ctx, sql, args...) + if err != nil { + return 0, fmt.Errorf("clear addresses: %w", err) + } + return tag.RowsAffected(), nil +} + +var qAddressCount = register("AddressCount", `SELECT count(*) FROM pwikit_user_address`) + +func (d *DB) AddressCount(ctx context.Context) (int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAddressCount).Scan(&total); err != nil { + return 0, fmt.Errorf("count addresses: %w", err) + } + return total, nil +} diff --git a/internal/db/admin_write_test.go b/internal/db/admin_write_test.go new file mode 100644 index 00000000..a9fc2013 --- /dev/null +++ b/internal/db/admin_write_test.go @@ -0,0 +1,86 @@ +package db + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func scratchImportedUser(t *testing.T, d *DB, name string) int64 { + t.Helper() + ctx := context.Background() + name = name + "-" + time.Now().Format("150405.000000") + var id int64 + err := d.pool.QueryRow(ctx, ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, wikidot_username, display_name, type, bio, is_forum_active, is_active, + can_send_direct_messages, pending_email, previous_email) +VALUES ('!', false, '', '', '', now(), $1, $2, $3, 'wikidot', '', true, false, true, '', '') +RETURNING id`, strings.ToLower(name), name, name).Scan(&id) + if err != nil { + t.Fatalf("insert imported user err = %v, want nil", err) + } + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), `DELETE FROM web_user WHERE id = $1`, id); err != nil { + t.Errorf("delete imported user err = %v, want nil", err) + } + }) + return id +} + +func TestUserToClaimMatchesTheArchiveSpelling(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchImportedUser(t, d, "Probe Claim") + + stored, err := d.UserByID(ctx, id) + if err != nil { + t.Fatalf("UserByID() err = %v, want nil", err) + } + got, hash, err := d.UserToClaim(ctx, stored.Username, strings.ToLower(stored.WikidotUsername)) + if err != nil { + t.Fatalf("UserToClaim() err = %v, want nil", err) + } + if got.ID != id { + t.Errorf("UserToClaim().ID = %d, want %d", got.ID, id) + } + if hash != "!" { + t.Errorf("UserToClaim() hash = %q, want %q", hash, "!") + } +} + +func TestUserToClaimIsNotFoundForAnUnknownName(t *testing.T) { + d := writeTestDB(t) + _, _, err := d.UserToClaim(context.Background(), "probe-no-such-name", "probe-no-such-name") + if !errors.Is(err, ErrNotFound) { + t.Errorf("UserToClaim() err = %v, want ErrNotFound", err) + } +} + +func TestSetSuperuserGoesBothWays(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchImportedUser(t, d, "Probe Super") + + for _, want := range []bool{true, false} { + if err := d.SetSuperuser(ctx, id, want); err != nil { + t.Fatalf("SetSuperuser(%t) err = %v, want nil", want, err) + } + got, err := d.UserByID(ctx, id) + if err != nil { + t.Fatalf("UserByID() err = %v, want nil", err) + } + if got.IsSuperuser != want { + t.Errorf("UserByID().IsSuperuser = %t, want %t", got.IsSuperuser, want) + } + } +} + +func TestSetSuperuserIsNotFoundForAnUnknownID(t *testing.T) { + d := writeTestDB(t) + if err := d.SetSuperuser(context.Background(), -1, true); !errors.Is(err, ErrNotFound) { + t.Errorf("SetSuperuser() err = %v, want ErrNotFound", err) + } +} diff --git a/internal/db/archive_write.go b/internal/db/archive_write.go new file mode 100644 index 00000000..cfb807f2 --- /dev/null +++ b/internal/db/archive_write.go @@ -0,0 +1,424 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// LogWikidot marks a revision an import carried over whose kind this engine has +// no word for, such as a rename Wikidot recorded without keeping the source. +const LogWikidot = "wikidot" + +type ImportUser struct { + WikidotID int64 + Username string + DisplayName string +} + +var ( + qUsersByWikidotID = register("UsersByWikidotID", ` +SELECT wikidot_user_id, id FROM web_user WHERE wikidot_user_id = ANY($1)`) + + qUsersByWikidotName = register("UsersByWikidotName", ` +SELECT wikidot_username, id FROM web_user WHERE wikidot_username = ANY($1)`) + + qAdoptWikidotID = register("AdoptWikidotID", ` +UPDATE web_user SET wikidot_user_id = $2 WHERE id = $1 AND wikidot_user_id IS NULL`) + + qInsertWikidotUser = register("InsertWikidotUser", ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, wikidot_username, wikidot_user_id, display_name, type, bio, + is_forum_active, is_active, can_send_direct_messages, pending_email, previous_email) +VALUES ('!', false, '', '', '', $4, $5, $1, $2, $3, 'wikidot', '', true, false, true, '', '') +RETURNING id`) +) + +// EnsureWikidotUsers answers the local id of every account the archive names, +// creating the ones this database has never seen. Nothing already here is +// changed, so importing a second archive that overlaps adds rows and edits none. +func (d *DB) EnsureWikidotUsers(ctx context.Context, users []ImportUser, at time.Time) (map[int64]int64, error) { + out := make(map[int64]int64, len(users)) + if len(users) == 0 { + return out, nil + } + + ids := make([]int64, 0, len(users)) + names := make([]string, 0, len(users)) + for _, u := range users { + ids = append(ids, u.WikidotID) + if u.Username != "" { + names = append(names, u.Username) + } + } + + rows, err := d.pool.Query(ctx, qUsersByWikidotID, ids) + if err != nil { + return nil, fmt.Errorf("look up imported users by id: %w", err) + } + for rows.Next() { + var wikidotID, local int64 + if err := rows.Scan(&wikidotID, &local); err != nil { + rows.Close() + return nil, err + } + out[wikidotID] = local + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + // An account the previous importer created carries the name but not the + // number, so the number is written onto it rather than a second row. + byName := map[string]int64{} + if len(names) > 0 { + rows, err := d.pool.Query(ctx, qUsersByWikidotName, names) + if err != nil { + return nil, fmt.Errorf("look up imported users by name: %w", err) + } + for rows.Next() { + var name string + var local int64 + if err := rows.Scan(&name, &local); err != nil { + rows.Close() + return nil, err + } + byName[lowered(name)] = local + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + + for _, u := range users { + if _, ok := out[u.WikidotID]; ok { + continue + } + if local, ok := byName[lowered(u.Username)]; ok { + if _, err := d.pool.Exec(ctx, qAdoptWikidotID, local, u.WikidotID); err != nil { + return nil, fmt.Errorf("adopt wikidot id %d: %w", u.WikidotID, err) + } + out[u.WikidotID] = local + continue + } + name := u.Username + if name == "" { + name = fmt.Sprintf("deleted-%d", u.WikidotID) + } + local, err := d.insertWikidotUser(ctx, u.WikidotID, name, u.DisplayName, at) + if err != nil { + return nil, err + } + out[u.WikidotID] = local + byName[lowered(name)] = local + } + return out, nil +} + +func (d *DB) insertWikidotUser(ctx context.Context, wikidotID int64, name, display string, at time.Time) (int64, error) { + local, err := mediaName() + if err != nil { + return 0, err + } + var id int64 + err = d.pool.QueryRow(ctx, qInsertWikidotUser, name, wikidotID, display, at, local).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create the imported account %q: %w", name, err) + } + return id, nil +} + +type ImportRevision struct { + Number int + UserID *int64 + Comment string + At time.Time + + // Source is nil for a revision Wikidot recorded without keeping the text, + // which is most of them once a page has been renamed or retagged. + Source *string + IsNew bool +} + +type ImportVote struct { + UserID int64 + Rate float64 +} + +type ImportArticle struct { + Category string + Name string + Title string + Locked bool + CreatedAt time.Time + UpdatedAt time.Time + AuthorID *int64 + + // Indexed is the newest source. The import has no renderer, so the raw text + // stands in for the rendering until the page is next edited. + Indexed string + + Revisions []ImportRevision + Votes []ImportVote + TagIDs []int64 +} + +var ( + qImportArticle = register("ImportArticle", ` +INSERT INTO web_article (site_id, category, name, title, locked, created_at, updated_at, media_name) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id`) + + qImportLogEntry = register("ImportLogEntry", ` +INSERT INTO web_articlelogentry (article_id, user_id, type, meta, comment, created_at, rev_number) +VALUES ($1, $2, $3, $4, $5, $6, $7)`) + + qImportVote = register("ImportVote", ` +INSERT INTO web_vote (article_id, user_id, rate, date) +VALUES ($1, $2, $3, NULL) +ON CONFLICT DO NOTHING`) + + qImportArticleTag = register("ImportArticleTag", ` +INSERT INTO web_article_tags (article_id, tag_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING`) +) + +// ImportArticle writes one page and its whole history as the archive recorded +// it. It goes around the ordinary save path on purpose, since that one renumbers +// revisions, rebuilds links and tells subscribers about every one of them. +func (d *DB) ImportArticle(ctx context.Context, siteID int64, a ImportArticle) (int64, string, error) { + media, err := mediaName() + if err != nil { + return 0, "", err + } + + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, "", fmt.Errorf("begin importing %q: %w", a.Name, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + var id int64 + err = tx.QueryRow(ctx, qImportArticle, siteID, a.Category, a.Name, a.Title, a.Locked, + a.CreatedAt, a.UpdatedAt, media).Scan(&id) + if err != nil { + return 0, "", fmt.Errorf("import article %q: %w", a.Name, err) + } + if a.AuthorID != nil { + if _, err := tx.Exec(ctx, qInsertArticleAuthor, id, *a.AuthorID); err != nil { + return 0, "", fmt.Errorf("credit the author of %q: %w", a.Name, err) + } + } + + for _, rev := range a.Revisions { + kind := LogWikidot + meta := map[string]any{} + if rev.Source != nil { + var versionID int64 + err := tx.QueryRow(ctx, qInsertArticleVersion, id, *rev.Source, rev.At).Scan(&versionID) + if err != nil { + return 0, "", fmt.Errorf("import a version of %q: %w", a.Name, err) + } + kind = LogSource + meta["version_id"] = versionID + if rev.IsNew { + kind = LogNew + meta["title"] = a.Title + } + } + encoded, err := json.Marshal(meta) + if err != nil { + return 0, "", err + } + _, err = tx.Exec(ctx, qImportLogEntry, id, rev.UserID, kind, encoded, rev.Comment, rev.At, rev.Number) + if err != nil { + return 0, "", fmt.Errorf("import a revision of %q: %w", a.Name, err) + } + } + + if a.Indexed != "" { + text := a.Title + "\n\n" + a.Indexed + if _, err := tx.Exec(ctx, qInsertSearchIndex, id, text, text); err != nil { + return 0, "", fmt.Errorf("index %q: %w", a.Name, err) + } + } + + for _, vote := range a.Votes { + if _, err := tx.Exec(ctx, qImportVote, id, vote.UserID, vote.Rate); err != nil { + return 0, "", fmt.Errorf("import a vote on %q: %w", a.Name, err) + } + } + for _, tagID := range a.TagIDs { + if _, err := tx.Exec(ctx, qImportArticleTag, id, tagID); err != nil { + return 0, "", fmt.Errorf("tag %q: %w", a.Name, err) + } + } + + if err := tx.Commit(ctx); err != nil { + return 0, "", fmt.Errorf("commit %q: %w", a.Name, err) + } + return id, media, nil +} + +var qImportParent = register("ImportParent", ` +UPDATE web_article SET parent_id = $2 WHERE id = $1 AND site_id = $3`) + +func (d *DB) SetImportedParent(ctx context.Context, siteID, articleID, parentID int64) error { + if _, err := d.pool.Exec(ctx, qImportParent, articleID, parentID, siteID); err != nil { + return fmt.Errorf("set the parent of %d: %w", articleID, err) + } + return nil +} + +func lowered(s string) string { return strings.ToLower(s) } + +// EnsureTags resolves the tag names an archive carries, creating the ones this +// site has never had. It is the import's way in to the same resolution the +// editor uses, so a tag written here is the tag the editor would have made. +func (d *DB) EnsureTags(ctx context.Context, siteID int64, names []string, allowCreate bool) ([]int64, error) { + if len(names) == 0 { + return nil, nil + } + tx, err := d.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin resolving tags: %w", err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + tags, err := resolveTags(ctx, tx, siteID, names, allowCreate) + if err != nil { + return nil, err + } + out := make([]int64, 0, len(tags)) + for _, tag := range tags { + out = append(out, tag.ID) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit tags: %w", err) + } + return out, nil +} + +// MediaName picks the name a file is stored under, which an importer needs +// before it copies the bytes into place. +func MediaName() (string, error) { return mediaName() } + +type ImportThread struct { + CategoryID *int64 + ArticleID *int64 + Name string + Description string + AuthorID *int64 + CreatedAt time.Time + UpdatedAt time.Time + Pinned bool + Locked bool +} + +type ImportPost struct { + Name string + AuthorID *int64 + ReplyTo *int64 + CreatedAt time.Time + Versions []ImportPostVersion +} + +type ImportPostVersion struct { + Source string + AuthorID *int64 + At time.Time +} + +var ( + qImportThread = register("ImportThread", ` +INSERT INTO web_forumthread (site_id, category_id, article_id, name, description, author_id, + created_at, updated_at, is_pinned, is_locked) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id`) + + qImportPost = register("ImportPost", ` +INSERT INTO web_forumpost (thread_id, name, author_id, reply_to_id, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id`) + + qImportPostVersion = register("ImportPostVersion", ` +INSERT INTO web_forumpostversion (post_id, source, author_id, created_at) +VALUES ($1, $2, $3, $4)`) +) + +func (d *DB) ImportForumSection(ctx context.Context, siteID int64, name, description string) (int64, error) { + var id int64 + err := d.pool.QueryRow(ctx, qInsertForumSection, name, description, 0, false, false, siteID).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create the imported forum section: %w", err) + } + return id, nil +} + +func (d *DB) ImportForumCategory(ctx context.Context, siteID, sectionID int64, name, description string, + order int, forComments bool) (int64, error) { + + var id int64 + err := d.pool.QueryRow(ctx, qInsertForumCategory, name, description, order, forComments, sectionID, siteID).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create the imported forum category %q: %w", name, err) + } + return id, nil +} + +// ImportForumThread writes a thread and its posts together. A reply names its +// parent by the position the parent holds in posts, so the caller flattens the +// tree with every parent ahead of its children. +func (d *DB) ImportForumThread(ctx context.Context, siteID int64, t ImportThread, posts []ImportPost, + parents []int) (int, error) { + + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin importing a thread: %w", err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + var threadID int64 + err = tx.QueryRow(ctx, qImportThread, siteID, t.CategoryID, t.ArticleID, t.Name, t.Description, + t.AuthorID, t.CreatedAt, t.UpdatedAt, t.Pinned, t.Locked).Scan(&threadID) + if err != nil { + return 0, fmt.Errorf("import the thread %q: %w", t.Name, err) + } + + ids := make([]int64, len(posts)) + written := 0 + for i, post := range posts { + var replyTo *int64 + if parents[i] >= 0 { + replyTo = &ids[parents[i]] + } + updated := post.CreatedAt + if n := len(post.Versions); n > 0 { + updated = post.Versions[n-1].At + } + var postID int64 + err := tx.QueryRow(ctx, qImportPost, threadID, post.Name, post.AuthorID, replyTo, + post.CreatedAt, updated).Scan(&postID) + if err != nil { + return written, fmt.Errorf("import a post of %q: %w", t.Name, err) + } + ids[i] = postID + for _, version := range post.Versions { + _, err := tx.Exec(ctx, qImportPostVersion, postID, version.Source, version.AuthorID, version.At) + if err != nil { + return written, fmt.Errorf("import a post version of %q: %w", t.Name, err) + } + } + written++ + } + + if err := tx.Commit(ctx); err != nil { + return written, fmt.Errorf("commit the thread %q: %w", t.Name, err) + } + return written, nil +} diff --git a/internal/db/article.go b/internal/db/article.go new file mode 100644 index 00000000..9c6e791e --- /dev/null +++ b/internal/db/article.go @@ -0,0 +1,106 @@ +package db + +import ( + "context" + "fmt" + "strings" +) + +// complete_full_name is a generated column that always carries an explicit +// category, so a bare page name grows the implicit _default one before it can +// match anything. +func dumbName(ref string) string { + if strings.Contains(ref, ":") { + return strings.ToLower(ref) + } + return "_default:" + strings.ToLower(ref) +} + +func dumbNames(refs []string) []string { + out := make([]string, len(refs)) + for i, ref := range refs { + out[i] = dumbName(ref) + } + return out +} + +var qArticleTitles = register("ArticleTitles", ` +SELECT complete_full_name, title +FROM web_article +WHERE site_id = $1 AND complete_full_name = ANY($2)`) + +// ArticleTitles keys its result by the caller's own ref strings. Refs with no +// article are absent from the map rather than present-and-empty: fetch_internal_links +// drops missing pages instead of reporting them as non-existent. +func (d *DB) ArticleTitles(ctx context.Context, siteID int64, refs []string) (map[string]string, error) { + if len(refs) == 0 { + return map[string]string{}, nil + } + + rows, err := d.pool.Query(ctx, qArticleTitles, siteID, dumbNames(refs)) + if err != nil { + return nil, fmt.Errorf("query article titles: %w", err) + } + defer rows.Close() + + byDumb := make(map[string]string) + for rows.Next() { + var fullName, title string + if err := rows.Scan(&fullName, &title); err != nil { + return nil, fmt.Errorf("scan article title: %w", err) + } + byDumb[strings.ToLower(fullName)] = title + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read article titles: %w", err) + } + + out := make(map[string]string, len(refs)) + for _, ref := range refs { + if title, ok := byDumb[dumbName(ref)]; ok { + out[ref] = title + } + } + return out, nil +} + +var qArticleSources = register("ArticleSources", ` +SELECT DISTINCT ON (a.id) a.complete_full_name, v.source +FROM web_articleversion v +JOIN web_article a ON a.id = v.article_id +WHERE a.site_id = $1 AND a.complete_full_name = ANY($2) +ORDER BY a.id, v.created_at DESC`) + +// ArticleSources returns the newest version's source per article, keyed by the +// caller's own ref strings. +func (d *DB) ArticleSources(ctx context.Context, siteID int64, refs []string) (map[string]string, error) { + if len(refs) == 0 { + return map[string]string{}, nil + } + + rows, err := d.pool.Query(ctx, qArticleSources, siteID, dumbNames(refs)) + if err != nil { + return nil, fmt.Errorf("query article sources: %w", err) + } + defer rows.Close() + + byDumb := make(map[string]string) + for rows.Next() { + var fullName, source string + if err := rows.Scan(&fullName, &source); err != nil { + return nil, fmt.Errorf("scan article source: %w", err) + } + byDumb[strings.ToLower(fullName)] = source + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read article sources: %w", err) + } + + out := make(map[string]string, len(refs)) + for _, ref := range refs { + if source, ok := byDumb[dumbName(ref)]; ok { + out[ref] = source + } + } + return out, nil +} diff --git a/internal/db/article_delete.go b/internal/db/article_delete.go new file mode 100644 index 00000000..eee11065 --- /dev/null +++ b/internal/db/article_delete.go @@ -0,0 +1,57 @@ +package db + +import ( + "context" + "fmt" +) + +// Nothing in the schema cascades, so every row that points at the page has to +// be named here. The order is the one the foreign keys allow. +var articleChildren = []string{ + `DELETE FROM web_forumpostversion WHERE post_id IN ( + SELECT p.id FROM web_forumpost p + JOIN web_forumthread t ON t.id = p.thread_id + WHERE t.article_id = $1)`, + `UPDATE web_forumpost SET reply_to_id = NULL WHERE thread_id IN ( + SELECT id FROM web_forumthread WHERE article_id = $1)`, + `DELETE FROM web_forumpost WHERE thread_id IN ( + SELECT id FROM web_forumthread WHERE article_id = $1)`, + `DELETE FROM web_usernotificationsubscription WHERE forum_thread_id IN ( + SELECT id FROM web_forumthread WHERE article_id = $1)`, + `DELETE FROM web_forumthread WHERE article_id = $1`, + `DELETE FROM web_usernotificationsubscription WHERE article_id = $1`, + `DELETE FROM web_vote WHERE article_id = $1`, + `DELETE FROM web_file WHERE article_id = $1`, + `DELETE FROM web_article_tags WHERE article_id = $1`, + `DELETE FROM web_article_authors WHERE article_id = $1`, + `DELETE FROM web_articlelogentry WHERE article_id = $1`, + `DELETE FROM web_articleversion WHERE article_id = $1`, + `UPDATE web_articlesearchindex SET article_id = NULL WHERE article_id = $1`, + `UPDATE web_article SET parent_id = NULL WHERE parent_id = $1`, + `DELETE FROM web_article WHERE id = $1`, +} + +var qDeleteArticle = registerAll("DeleteArticle", articleChildren) + +// The files on disk are left to the caller, which is the only layer that knows +// where the state directory is. +func (d *DB) DeleteArticle(ctx context.Context, siteID, articleID int64, fullName string) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin delete of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qDropLinksFrom, fullName, siteID); err != nil { + return fmt.Errorf("drop links of %q: %w", fullName, err) + } + for _, sql := range qDeleteArticle { + if _, err := tx.Exec(ctx, sql, articleID); err != nil { + return fmt.Errorf("delete article %d: %w", articleID, err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit delete of %d: %w", articleID, err) + } + return nil +} diff --git a/internal/db/article_log.go b/internal/db/article_log.go new file mode 100644 index 00000000..cba15211 --- /dev/null +++ b/internal/db/article_log.go @@ -0,0 +1,77 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type LogEntry struct { + RevNumber int + Type string + Meta []byte + Comment string + CreatedAt time.Time + UserID *int64 +} + +var ( + // A null limit asks for the whole history, which is what the caller wants + // when it is not paging. + qArticleLog = register("ArticleLog", ` +SELECT rev_number, type, meta, comment, created_at, user_id +FROM web_articlelogentry +WHERE article_id = $1 +ORDER BY rev_number DESC +OFFSET $2 LIMIT $3`) + + qArticleLogCount = register("ArticleLogCount", ` +SELECT count(*) FROM web_articlelogentry WHERE article_id = $1`) +) + +func (d *DB) ArticleLog(ctx context.Context, articleID int64, offset int, limit *int) ([]LogEntry, error) { + rows, err := d.pool.Query(ctx, qArticleLog, articleID, offset, limit) + if err != nil { + return nil, fmt.Errorf("query log of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []LogEntry + for rows.Next() { + var e LogEntry + if err := rows.Scan(&e.RevNumber, &e.Type, &e.Meta, &e.Comment, &e.CreatedAt, &e.UserID); err != nil { + return nil, fmt.Errorf("scan log entry of article %d: %w", articleID, err) + } + out = append(out, e) + } + return out, rows.Err() +} + +func (d *DB) ArticleLogCount(ctx context.Context, articleID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qArticleLogCount, articleID).Scan(&n); err != nil { + return 0, fmt.Errorf("count log of article %d: %w", articleID, err) + } + return n, nil +} + +var qLogEntryByID = register("LogEntryByID", ` +SELECT rev_number, type, meta, comment, created_at, user_id +FROM web_articlelogentry +WHERE id = $1`) + +func (d *DB) LogEntryByID(ctx context.Context, id int64) (LogEntry, error) { + var e LogEntry + err := d.pool.QueryRow(ctx, qLogEntryByID, id). + Scan(&e.RevNumber, &e.Type, &e.Meta, &e.Comment, &e.CreatedAt, &e.UserID) + if errors.Is(err, pgx.ErrNoRows) { + return LogEntry{}, ErrNotFound + } + if err != nil { + return LogEntry{}, fmt.Errorf("read log entry %d: %w", id, err) + } + return e, nil +} diff --git a/internal/db/article_meta.go b/internal/db/article_meta.go new file mode 100644 index 00000000..1d4740a6 --- /dev/null +++ b/internal/db/article_meta.go @@ -0,0 +1,210 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +var qArticleByID = register("ArticleByID", ` +SELECT `+articleColumns+` +FROM web_article +WHERE id = $1 AND site_id = $2`) + +func (d *DB) ArticleByID(ctx context.Context, siteID, id int64) (*Article, error) { + var a Article + err := d.pool.QueryRow(ctx, qArticleByID, id, siteID).Scan( + &a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, &a.Locked, + &a.CreatedAt, &a.UpdatedAt, &a.MediaName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup article %d: %w", id, err) + } + return &a, nil +} + +var qLatestSource = register("LatestSource", ` +SELECT source +FROM web_articleversion +WHERE article_id = $1 +ORDER BY created_at DESC +LIMIT 1`) + +func (d *DB) LatestSource(ctx context.Context, articleID int64) (string, error) { + var source string + err := d.pool.QueryRow(ctx, qLatestSource, articleID).Scan(&source) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query latest source of article %d: %w", articleID, err) + } + return source, nil +} + +var qSourceAtRevision = register("SourceAtRevision", ` +SELECT v.source +FROM web_articlelogentry e +JOIN web_articleversion v ON v.id = coalesce( + (e.meta -> 'source' ->> 'version_id')::bigint, + (e.meta ->> 'version_id')::bigint) +WHERE e.article_id = $1 + AND e.rev_number <= $2 + AND EXISTS (SELECT 1 FROM web_articlelogentry x + WHERE x.article_id = $1 AND x.rev_number = $2) +ORDER BY e.rev_number DESC +LIMIT 1`) + +// A revision that only renamed or retagged the page records no version, so the +// source of the newest earlier revision that does is the source it had. +func (d *DB) SourceAtRevision(ctx context.Context, articleID int64, revNumber int) (string, error) { + var source string + err := d.pool.QueryRow(ctx, qSourceAtRevision, articleID, revNumber).Scan(&source) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query source of article %d at revision %d: %w", articleID, revNumber, err) + } + return source, nil +} + +// The join table carries no ordering of its own, so ordering by the link row is +// what freezes the author order a page shows. +var qArticleAuthors = register("ArticleAuthors", ` +SELECT `+prefixed("u", userColumns)+` +FROM web_article_authors link +JOIN web_user u ON u.id = link.user_id +WHERE link.article_id = $1 +ORDER BY link.id`) + +func (d *DB) ArticleAuthors(ctx context.Context, articleID int64) ([]User, error) { + rows, err := d.pool.Query(ctx, qArticleAuthors, articleID) + if err != nil { + return nil, fmt.Errorf("query authors of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + dest, finish := userDest(&u) + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan author: %w", err) + } + finish() + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read authors of article %d: %w", articleID, err) + } + return out, nil +} + +var qLatestEditor = register("LatestEditor", ` +SELECT `+prefixed("u", userColumns)+` +FROM web_articlelogentry e +JOIN web_user u ON u.id = e.user_id +WHERE e.article_id = $1 +ORDER BY e.rev_number DESC +LIMIT 1`) + +func (d *DB) LatestEditor(ctx context.Context, articleID int64) (*User, error) { + var u User + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, qLatestEditor, articleID).Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query latest editor of article %d: %w", articleID, err) + } + finish() + return &u, nil +} + +var qRevisionCount = register("RevisionCount", ` +SELECT count(*) +FROM web_articlelogentry +WHERE article_id = $1`) + +func (d *DB) RevisionCount(ctx context.Context, articleID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qRevisionCount, articleID).Scan(&n); err != nil { + return 0, fmt.Errorf("count revisions of article %d: %w", articleID, err) + } + return n, nil +} + +var qChildCount = register("ChildCount", ` +SELECT count(*) +FROM web_article +WHERE parent_id = $1`) + +func (d *DB) ChildCount(ctx context.Context, articleID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qChildCount, articleID).Scan(&n); err != nil { + return 0, fmt.Errorf("count children of article %d: %w", articleID, err) + } + return n, nil +} + +var qCommentCount = register("CommentCount", ` +SELECT count(*) +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +WHERE t.article_id = $1`) + +func (d *DB) CommentCount(ctx context.Context, articleID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qCommentCount, articleID).Scan(&n); err != nil { + return 0, fmt.Errorf("count comments of article %d: %w", articleID, err) + } + return n, nil +} + +var qArticleLastComment = register("ArticleLastComment", ` +SELECT `+forumLastPostColumns+` +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +WHERE t.article_id = $1 +ORDER BY p.created_at DESC +LIMIT 1`) + +func (d *DB) ArticleLastComment(ctx context.Context, articleID int64) (*ForumLastPost, error) { + return d.scanLastPost(ctx, qArticleLastComment, articleID) +} + +var qArticleTags = register("ArticleTags", ` +SELECT CASE WHEN c.slug = '_default' THEN t.name ELSE c.slug || ':' || t.name END +FROM web_article_tags link +JOIN web_tag t ON t.id = link.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE link.article_id = $1`) + +// ArticleTags returns full names unsorted; the caller lowercases and sorts, +// because that pass belongs to whoever renders them. +func (d *DB) ArticleTags(ctx context.Context, articleID int64) ([]string, error) { + rows, err := d.pool.Query(ctx, qArticleTags, articleID) + if err != nil { + return nil, fmt.Errorf("query tags of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan tag: %w", err) + } + out = append(out, name) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read tags of article %d: %w", articleID, err) + } + return out, nil +} diff --git a/internal/db/article_revert.go b/internal/db/article_revert.go new file mode 100644 index 00000000..63c2dbcd --- /dev/null +++ b/internal/db/article_revert.go @@ -0,0 +1,316 @@ +package db + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const LogRevert = "revert" + +type RestoredVote struct { + UserID int64 + RoleID *int64 + Rate float64 + Date *time.Time +} + +var ( + qArticleLogAbove = register("ArticleLogAbove", ` +SELECT rev_number, type, meta, comment, created_at, user_id +FROM web_articlelogentry +WHERE article_id = $1 AND rev_number > $2 +ORDER BY rev_number DESC`) + + // The version before another is the one written last before it, which is not + // the one with the next lower id when an import wrote them out of order. + qPreviousVersionSource = register("PreviousVersionSource", ` +SELECT p.source +FROM web_articleversion v +JOIN web_articleversion p ON p.article_id = v.article_id AND p.created_at < v.created_at +WHERE v.id = $1 +ORDER BY p.created_at DESC +LIMIT 1`) +) + +func (d *DB) ArticleLogAbove(ctx context.Context, articleID int64, revNumber int) ([]LogEntry, error) { + rows, err := d.pool.Query(ctx, qArticleLogAbove, articleID, revNumber) + if err != nil { + return nil, fmt.Errorf("query log of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []LogEntry + for rows.Next() { + var e LogEntry + if err := rows.Scan(&e.RevNumber, &e.Type, &e.Meta, &e.Comment, &e.CreatedAt, &e.UserID); err != nil { + return nil, fmt.Errorf("scan log entry of article %d: %w", articleID, err) + } + out = append(out, e) + } + return out, rows.Err() +} + +func (d *DB) PreviousVersionSource(ctx context.Context, versionID int64) (string, error) { + var source string + err := d.pool.QueryRow(ctx, qPreviousVersionSource, versionID).Scan(&source) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query version before %d: %w", versionID, err) + } + return source, nil +} + +var ( + qReadFileName = register("ReadFileName", ` +SELECT name FROM web_file WHERE id = $1 FOR UPDATE`) + + qRenameFile = register("RenameFile", ` +UPDATE web_file SET name = $2 WHERE id = $1`) + + qSoftDeleteFile = register("SoftDeleteFile", ` +UPDATE web_file SET deleted_at = $2, deleted_by_id = $3 +WHERE id = $1 AND deleted_at IS NULL +RETURNING name`) + + qRestoreFile = register("RestoreFile", ` +UPDATE web_file SET deleted_at = NULL, deleted_by_id = NULL +WHERE id = $1 AND deleted_at IS NOT NULL +RETURNING name`) +) + +// A file that is already in the asked-for state reports no name, which is how +// the caller knows to leave it out of the revision it writes. +func (d *DB) RenameFile(ctx context.Context, fileID int64, name string) (string, bool, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return "", false, fmt.Errorf("begin rename of file %d: %w", fileID, err) + } + defer tx.Rollback(ctx) + + var previous string + err = tx.QueryRow(ctx, qReadFileName, fileID).Scan(&previous) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("read file %d: %w", fileID, err) + } + if _, err := tx.Exec(ctx, qRenameFile, fileID, name); err != nil { + return "", false, fmt.Errorf("rename file %d: %w", fileID, err) + } + if err := tx.Commit(ctx); err != nil { + return "", false, fmt.Errorf("commit rename of file %d: %w", fileID, err) + } + return previous, true, nil +} + +func (d *DB) SoftDeleteFile(ctx context.Context, fileID int64, at time.Time, byUserID *int64) (string, bool, error) { + return d.fileName(ctx, qSoftDeleteFile, fileID, at, byUserID) +} + +func (d *DB) RestoreFile(ctx context.Context, fileID int64) (string, bool, error) { + return d.fileName(ctx, qRestoreFile, fileID) +} + +func (d *DB) fileName(ctx context.Context, sql string, fileID int64, args ...any) (string, bool, error) { + var name string + err := d.pool.QueryRow(ctx, sql, append([]any{fileID}, args...)...).Scan(&name) + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("touch file %d: %w", fileID, err) + } + return name, true, nil +} + +var ( + qSetArticleTagIDs = register("SetArticleTagIDs", ` +DELETE FROM web_article_tags WHERE article_id = $1 AND NOT (tag_id = ANY($2))`) + + qKnownTags = register("KnownTags", `SELECT id FROM web_tag WHERE id = ANY($1)`) + + qKnownUser = register("KnownUser", `SELECT id FROM web_user WHERE id = $1`) +) + +// The revision that records this is written by the caller, so nothing is logged +// here. +func (d *DB) SetArticleTagIDs(ctx context.Context, articleID int64, tagIDs []int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tags of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + wanted, err := scanIDs(ctx, tx, qKnownTags, tagIDs) + if err != nil { + return fmt.Errorf("look up tags of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qSetArticleTagIDs, articleID, wanted); err != nil { + return fmt.Errorf("drop tags of %d: %w", articleID, err) + } + held, err := scanIDs(ctx, tx, qArticleTagIDs, articleID) + if err != nil { + return fmt.Errorf("read tags of %d: %w", articleID, err) + } + for _, id := range missingFrom(wanted, held) { + if _, err := tx.Exec(ctx, qInsertArticleTag, articleID, id); err != nil { + return fmt.Errorf("tag %d: %w", articleID, err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit tags of %d: %w", articleID, err) + } + return nil +} + +// A vote whose voter is gone is dropped rather than failing the restore, since +// the row it would need points at a user that no longer exists. +func (d *DB) RestoreArticleVotes(ctx context.Context, articleID int64, votes []RestoredVote) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin votes of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qDeleteArticleVotes, articleID); err != nil { + return fmt.Errorf("drop votes of %d: %w", articleID, err) + } + for _, vote := range votes { + var known int64 + err := tx.QueryRow(ctx, qKnownUser, vote.UserID).Scan(&known) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return fmt.Errorf("look up voter %d: %w", vote.UserID, err) + } + if _, err := tx.Exec(ctx, qInsertVote, articleID, vote.UserID, vote.Rate, vote.Date, vote.RoleID); err != nil { + return fmt.Errorf("restore vote of %d: %w", vote.UserID, err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit votes of %d: %w", articleID, err) + } + return nil +} + +type RevertWrite struct { + ArticleID int64 + UserID *int64 + Meta json.RawMessage + At time.Time +} + +func (d *DB) WriteRevertEntry(ctx context.Context, w RevertWrite) (Revision, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("begin revert of %d: %w", w.ArticleID, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qLockArticleLog, w.ArticleID); err != nil { + return Revision{}, fmt.Errorf("lock article log %d: %w", w.ArticleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, w.ArticleID, w.UserID, LogRevert, + string(w.Meta), "", w.At).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, fmt.Errorf("write revision of %d: %w", w.ArticleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, w.ArticleID, w.At); err != nil { + return Revision{}, fmt.Errorf("touch article %d: %w", w.ArticleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("commit revert of %d: %w", w.ArticleID, err) + } + return rev, nil +} + +var ( + qAddArticleVersion = register("AddArticleVersion", ` +INSERT INTO web_articleversion (article_id, source, created_at) +VALUES ($1, $2, $3) +RETURNING id`) + + qSetArticleTitle = register("SetArticleTitle", ` +UPDATE web_article SET title = $2 WHERE id = $1`) + + qMoveArticleParent = register("MoveArticleParent", ` +UPDATE web_article SET parent_id = $2 WHERE id = $1`) + + qArticleAuthorIDs = register("ArticleAuthorIDs", ` +SELECT user_id FROM web_article_authors WHERE article_id = $1 ORDER BY user_id`) +) + +// The revision a revert writes names every piece it moved, so the pieces +// themselves leave no revisions of their own. +func (d *DB) AddArticleVersion(ctx context.Context, articleID int64, source string, at time.Time) (int64, error) { + var id int64 + if err := d.pool.QueryRow(ctx, qAddArticleVersion, articleID, source, at).Scan(&id); err != nil { + return 0, fmt.Errorf("write version of %d: %w", articleID, err) + } + return id, nil +} + +func (d *DB) SetArticleTitle(ctx context.Context, articleID int64, title string) error { + if _, err := d.pool.Exec(ctx, qSetArticleTitle, articleID, title); err != nil { + return fmt.Errorf("set title of %d: %w", articleID, err) + } + return nil +} + +func (d *DB) MoveArticleParent(ctx context.Context, articleID int64, parentID *int64) error { + if _, err := d.pool.Exec(ctx, qMoveArticleParent, articleID, parentID); err != nil { + return fmt.Errorf("set parent of %d: %w", articleID, err) + } + return nil +} + +func (d *DB) MoveArticle(ctx context.Context, articleID int64, category, name, from string) error { + to := (&Article{Category: category, Name: name}).FullName() + + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin move of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qRenameArticle, articleID, category, name); err != nil { + return fmt.Errorf("move article %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qDropLinksFrom, to); err != nil { + return fmt.Errorf("drop links of %q: %w", to, err) + } + if _, err := tx.Exec(ctx, qMoveLinksFrom, from, to); err != nil { + return fmt.Errorf("move links of %q: %w", from, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit move of %d: %w", articleID, err) + } + return nil +} + +func (d *DB) ArticleAuthorIDs(ctx context.Context, articleID int64) ([]int64, error) { + rows, err := d.pool.Query(ctx, qArticleAuthorIDs, articleID) + if err != nil { + return nil, fmt.Errorf("query authors of %d: %w", articleID, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan author of %d: %w", articleID, err) + } + out = append(out, id) + } + return out, rows.Err() +} diff --git a/internal/db/article_row.go b/internal/db/article_row.go new file mode 100644 index 00000000..1b0448e6 --- /dev/null +++ b/internal/db/article_row.go @@ -0,0 +1,68 @@ +package db + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type Article struct { + ID int64 + Category string + Name string + Title string + ParentID *int64 + Locked bool + CreatedAt time.Time + UpdatedAt time.Time + MediaName string +} + +// FullName drops the _default category, which exists in the column but never in +// a URL or a page reference. +func (a *Article) FullName() string { + if a.Category != DefaultCategory { + return a.Category + ":" + a.Name + } + return a.Name +} + +// DisplayName is what a breadcrumb or a link label shows when the page has no +// title of its own. +func (a *Article) DisplayName() string { + if title := strings.TrimSpace(a.Title); title != "" { + return title + } + return a.FullName() +} + +const DefaultCategory = "_default" + +const articleColumns = `id, category, name, title, parent_id, locked, created_at, updated_at, media_name` + +const prefixedArticleColumns = `a.id, a.category, a.name, a.title, a.parent_id, a.locked, a.created_at, a.updated_at, a.media_name` + +var qArticleByName = register("ArticleByName", ` +SELECT `+articleColumns+` +FROM web_article +WHERE site_id = $1 AND complete_full_name = $2`) + +// ArticleByName takes a page reference the way a URL spells it; dumbName puts +// the implicit category back so the generated column can match. +func (d *DB) ArticleByName(ctx context.Context, siteID int64, ref string) (*Article, error) { + var a Article + err := d.pool.QueryRow(ctx, qArticleByName, siteID, dumbName(ref)).Scan( + &a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, &a.Locked, + &a.CreatedAt, &a.UpdatedAt, &a.MediaName) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup article %q: %w", ref, err) + } + return &a, nil +} diff --git a/internal/db/article_row_test.go b/internal/db/article_row_test.go new file mode 100644 index 00000000..1e53b018 --- /dev/null +++ b/internal/db/article_row_test.go @@ -0,0 +1,79 @@ +package db + +import ( + "context" + "errors" + "testing" +) + +func TestArticleFullName(t *testing.T) { + cases := []struct { + category, name, want string + }{ + {DefaultCategory, "main", "main"}, + {"nav", "top", "nav:top"}, + {"component", "box", "component:box"}, + } + for _, c := range cases { + a := &Article{Category: c.category, Name: c.name} + if got := a.FullName(); got != c.want { + t.Errorf("Article{%q, %q}.FullName() = %q, want %q", c.category, c.name, got, c.want) + } + } +} + +func TestArticleDisplayName(t *testing.T) { + cases := []struct { + title, want string + }{ + {"Main Page", "Main Page"}, + {"", "nav:top"}, + {" ", "nav:top"}, + {" Padded ", "Padded"}, + } + for _, c := range cases { + a := &Article{Category: "nav", Name: "top", Title: c.title} + if got := a.DisplayName(); got != c.want { + t.Errorf("Article{Title: %q}.DisplayName() = %q, want %q", c.title, got, c.want) + } + } +} + +func TestArticleByName(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleByName(context.Background(), seedSiteID(t, d), "main") + if err != nil { + t.Fatalf("ArticleByName(%q) err = %v, want nil", "main", err) + } + if got.Name != "main" { + t.Errorf("ArticleByName(%q).Name = %q, want %q", "main", got.Name, "main") + } + if got.Category != DefaultCategory { + t.Errorf("ArticleByName(%q).Category = %q, want %q", "main", got.Category, DefaultCategory) + } + if got.MediaName == "" { + t.Errorf("ArticleByName(%q).MediaName = %q, want a uuid", "main", got.MediaName) + } +} + +func TestArticleByNameIsCaseInsensitive(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleByName(context.Background(), seedSiteID(t, d), "NAV:Top") + if err != nil { + t.Fatalf("ArticleByName(%q) err = %v, want nil", "NAV:Top", err) + } + if got.FullName() != "nav:top" { + t.Errorf("ArticleByName(%q).FullName() = %q, want %q", "NAV:Top", got.FullName(), "nav:top") + } +} + +func TestArticleByNameMissing(t *testing.T) { + d := newTestDB(t) + + _, err := d.ArticleByName(context.Background(), seedSiteID(t, d), "no-such-page") + if !errors.Is(err, ErrNotFound) { + t.Errorf("ArticleByName(%q) err = %v, want ErrNotFound", "no-such-page", err) + } +} diff --git a/internal/db/article_test.go b/internal/db/article_test.go new file mode 100644 index 00000000..e5a2d4c4 --- /dev/null +++ b/internal/db/article_test.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "errors" + "testing" +) + +func TestDumbName(t *testing.T) { + cases := []struct{ in, want string }{ + {"main", "_default:main"}, + {"MAIN", "_default:main"}, + {"nav:top", "nav:top"}, + {"NAV:Top", "nav:top"}, + {"_default:main", "_default:main"}, + {"", "_default:"}, + } + for _, c := range cases { + if got := dumbName(c.in); got != c.want { + t.Errorf("dumbName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestArticleTitlesKeysByCallerRef(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTitles(context.Background(), seedSiteID(t, d), []string{"main", "NAV:Top", "no-such-page"}) + if err != nil { + t.Fatalf("ArticleTitles() err = %v, want nil", err) + } + if len(got) != 2 { + t.Errorf("len(ArticleTitles()) = %d, want 2", len(got)) + } + if got["main"] != "main" { + t.Errorf("ArticleTitles()[\"main\"] = %q, want %q", got["main"], "main") + } + if got["NAV:Top"] != "top" { + t.Errorf("ArticleTitles()[\"NAV:Top\"] = %q, want %q", got["NAV:Top"], "top") + } + if _, ok := got["no-such-page"]; ok { + t.Error("ArticleTitles() contains \"no-such-page\", want it absent") + } +} + +func TestArticleTitlesEmptyRefs(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTitles(context.Background(), seedSiteID(t, d), nil) + if err != nil { + t.Fatalf("ArticleTitles(nil) err = %v, want nil", err) + } + if len(got) != 0 { + t.Errorf("len(ArticleTitles(nil)) = %d, want 0", len(got)) + } +} + +func TestArticleSourcesReturnsSource(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleSources(context.Background(), seedSiteID(t, d), []string{"main", "nav:top"}) + if err != nil { + t.Fatalf("ArticleSources() err = %v, want nil", err) + } + if _, ok := got["main"]; !ok { + t.Error("ArticleSources() is missing \"main\", want it present") + } + if got["main"] == "" { + t.Error("ArticleSources()[\"main\"] = \"\", want the page source") + } +} + +func TestSourceAtRevisionMatchesLatestOnTheOnlyRevision(t *testing.T) { + ctx := context.Background() + d := newTestDB(t) + + article, err := d.ArticleByName(ctx, seedSiteID(t, d), "probeoff:unratable") + if err != nil { + t.Fatalf("ArticleByName(probeoff:unratable) err = %v, want nil", err) + } + latest, err := d.LatestSource(ctx, article.ID) + if err != nil { + t.Fatalf("LatestSource() err = %v, want nil", err) + } + got, err := d.SourceAtRevision(ctx, article.ID, 0) + if err != nil { + t.Fatalf("SourceAtRevision(0) err = %v, want nil", err) + } + if got != latest { + t.Errorf("SourceAtRevision(0) = %q, want %q", got, latest) + } +} + +func TestSourceAtRevisionOfRevisionThatIsNotThere(t *testing.T) { + ctx := context.Background() + d := newTestDB(t) + + article, err := d.ArticleByName(ctx, seedSiteID(t, d), "probeoff:unratable") + if err != nil { + t.Fatalf("ArticleByName(probeoff:unratable) err = %v, want nil", err) + } + if _, err := d.SourceAtRevision(ctx, article.ID, 99); !errors.Is(err, ErrNotFound) { + t.Errorf("SourceAtRevision(99) err = %v, want ErrNotFound", err) + } +} diff --git a/internal/db/article_write.go b/internal/db/article_write.go new file mode 100644 index 00000000..488a19c2 --- /dev/null +++ b/internal/db/article_write.go @@ -0,0 +1,458 @@ +package db + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "slices" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + LogNew = "new" + LogSource = "source" + LogParent = "parent" + LogTitle = "title" + LogName = "name" + + LogAuthorship = "authorship" + + LogFileAdded = "file_added" + LogFileDeleted = "file_deleted" + LogFileRenamed = "file_renamed" +) + +type Revision struct { + VersionID int64 + EntryID int64 + RevNumber int +} + +type VersionWrite struct { + ArticleID int64 + Source string + UserID *int64 + Kind string + Comment string + At time.Time + + // Title rides along on the revision that created the page and nowhere else, + // so an empty one still has to be written. + Title string +} + +var qInsertArticleVersion = register("InsertArticleVersion", ` +INSERT INTO web_articleversion (article_id, source, created_at) +VALUES ($1, $2, $3) +RETURNING id`) + +// The version and the revision naming it go in together, so a failed write +// cannot leave behind a version nothing points at. +func (d *DB) CreateArticleVersion(ctx context.Context, w VersionWrite) (Revision, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("begin version: %w", err) + } + defer tx.Rollback(ctx) + + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleVersion, w.ArticleID, w.Source, w.At).Scan(&rev.VersionID); err != nil { + return Revision{}, fmt.Errorf("write version of %d: %w", w.ArticleID, err) + } + + meta, err := versionMeta(rev.VersionID, w) + if err != nil { + return Revision{}, err + } + if _, err := tx.Exec(ctx, qLockArticleLog, w.ArticleID); err != nil { + return Revision{}, fmt.Errorf("lock article log %d: %w", w.ArticleID, err) + } + if err := tx.QueryRow(ctx, qInsertArticleLog, w.ArticleID, w.UserID, w.Kind, meta, + w.Comment, w.At).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, fmt.Errorf("write revision of %d: %w", w.ArticleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, w.ArticleID, w.At); err != nil { + return Revision{}, fmt.Errorf("touch article %d: %w", w.ArticleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("commit version of %d: %w", w.ArticleID, err) + } + return rev, nil +} + +func versionMeta(versionID int64, w VersionWrite) (string, error) { + fields := map[string]any{"version_id": versionID} + if w.Kind == LogNew { + fields["title"] = w.Title + } + encoded, err := json.Marshal(fields) + if err != nil { + return "", fmt.Errorf("encode revision meta of %d: %w", w.ArticleID, err) + } + return string(encoded), nil +} + +type ArticleLink struct { + To string + Kind string + + // ToSiteID is nil for a target on the same site, which is every link that + // does not name one. + ToSiteID *int64 +} + +var ( + qDropArticleLinks = register("DropArticleLinks", ` +DELETE FROM web_externallink WHERE link_from = $1 AND from_site_id = $2`) + + qInsertArticleLinks = register("InsertArticleLinks", ` +INSERT INTO web_externallink (link_from, link_type, link_to, from_site_id, to_site_id) +SELECT $1, kind, target, $4, coalesce(site, $4) +FROM unnest($2::text[], $3::text[], $5::bigint[]) AS t(kind, target, site) +ON CONFLICT DO NOTHING`) +) + +// The whole set is replaced under one transaction, so nobody reads a page as +// having no links at all while it is being saved. +func (d *DB) ReplaceArticleLinks(ctx context.Context, siteID int64, from string, links []ArticleLink) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin links of %q: %w", from, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qDropArticleLinks, from, siteID); err != nil { + return fmt.Errorf("drop links of %q: %w", from, err) + } + if len(links) > 0 { + kinds := make([]string, 0, len(links)) + targets := make([]string, 0, len(links)) + sites := make([]*int64, 0, len(links)) + for _, link := range links { + kinds = append(kinds, link.Kind) + targets = append(targets, link.To) + sites = append(sites, link.ToSiteID) + } + if _, err := tx.Exec(ctx, qInsertArticleLinks, from, kinds, targets, siteID, sites); err != nil { + return fmt.Errorf("write links of %q: %w", from, err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit links of %q: %w", from, err) + } + return nil +} + +var ( + qInsertArticle = register("InsertArticle", ` +INSERT INTO web_article (site_id, category, name, title, locked, created_at, updated_at, media_name) +VALUES ($1, $2, $3, $4, false, $5, $5, $6) +RETURNING id`) + + qInsertArticleAuthor = register("InsertArticleAuthor", ` +INSERT INTO web_article_authors (article_id, user_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING`) +) + +func (d *DB) CreateArticle(ctx context.Context, siteID int64, category, name, title string, authorID *int64, at time.Time) (int64, error) { + media, err := mediaName() + if err != nil { + return 0, err + } + + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin article %q: %w", name, err) + } + defer tx.Rollback(ctx) + + var id int64 + if err := tx.QueryRow(ctx, qInsertArticle, siteID, category, name, title, at, media).Scan(&id); err != nil { + return 0, fmt.Errorf("write article %q: %w", name, err) + } + if authorID != nil { + if _, err := tx.Exec(ctx, qInsertArticleAuthor, id, *authorID); err != nil { + return 0, fmt.Errorf("credit author of %q: %w", name, err) + } + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit article %q: %w", name, err) + } + return id, nil +} + +// The directory a page's files live in is named after the row rather than after +// the page, so renaming a page moves nothing on disk. +func mediaName() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("pick a media name: %w", err) + } + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} + +var qSetArticleParent = register("SetArticleParent", ` +UPDATE web_article SET parent_id = $2 WHERE id = $1`) + +func (d *DB) SetArticleParent(ctx context.Context, articleID int64, parentID *int64, + userID *int64, meta string, at time.Time) (Revision, error) { + + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("begin parent of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qSetArticleParent, articleID, parentID); err != nil { + return Revision{}, fmt.Errorf("set parent of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qLockArticleLog, articleID); err != nil { + return Revision{}, fmt.Errorf("lock article log %d: %w", articleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, articleID, userID, LogParent, meta, "", at).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, fmt.Errorf("write revision of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, articleID, at); err != nil { + return Revision{}, fmt.Errorf("touch article %d: %w", articleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("commit parent of %d: %w", articleID, err) + } + return rev, nil +} + +// Nothing stops a second row for the same pair, so the check rides inside the +// insert rather than following it. +var qSubscribeToArticle = register("SubscribeToArticle", ` +INSERT INTO web_usernotificationsubscription (subscriber_id, article_id, forum_thread_id) +SELECT $1, $2, NULL +WHERE NOT EXISTS (SELECT 1 FROM web_usernotificationsubscription + WHERE subscriber_id = $1 AND article_id = $2 AND forum_thread_id IS NULL)`) + +func (d *DB) SubscribeToArticle(ctx context.Context, userID, articleID int64) error { + if _, err := d.pool.Exec(ctx, qSubscribeToArticle, userID, articleID); err != nil { + return fmt.Errorf("subscribe %d to article %d: %w", userID, articleID, err) + } + return nil +} + +var ( + qReadArticleTitle = register("ReadArticleTitle", ` +SELECT title FROM web_article WHERE id = $1 FOR UPDATE`) + + qUpdateArticleTitle = register("UpdateArticleTitle", ` +UPDATE web_article SET title = $2 WHERE id = $1`) +) + +// The old title is read under the same lock that replaces it, so the revision +// cannot name a title some other writer has already moved past. +func (d *DB) UpdateArticleTitle(ctx context.Context, articleID int64, title string, + userID *int64, at time.Time) (Revision, error) { + + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("begin title of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + var previous string + if err := tx.QueryRow(ctx, qReadArticleTitle, articleID).Scan(&previous); err != nil { + return Revision{}, fmt.Errorf("read title of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qUpdateArticleTitle, articleID, title); err != nil { + return Revision{}, fmt.Errorf("set title of %d: %w", articleID, err) + } + + meta, err := json.Marshal(map[string]any{"title": title, "prev_title": previous}) + if err != nil { + return Revision{}, fmt.Errorf("encode title meta of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qLockArticleLog, articleID); err != nil { + return Revision{}, fmt.Errorf("lock article log %d: %w", articleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, articleID, userID, LogTitle, string(meta), "", at).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, fmt.Errorf("write revision of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, articleID, at); err != nil { + return Revision{}, fmt.Errorf("touch article %d: %w", articleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("commit title of %d: %w", articleID, err) + } + return rev, nil +} + +// Locking leaves no revision behind, so the page history says nothing about it. +var qSetArticleLock = register("SetArticleLock", ` +UPDATE web_article SET locked = $2 WHERE id = $1`) + +func (d *DB) SetArticleLock(ctx context.Context, articleID int64, locked bool) error { + if _, err := d.pool.Exec(ctx, qSetArticleLock, articleID, locked); err != nil { + return fmt.Errorf("set lock of %d: %w", articleID, err) + } + return nil +} + +var ( + qKnownUsers = register("KnownUsers", ` +SELECT id FROM web_user WHERE id = ANY($1)`) + + qReadArticleAuthors = register("ReadArticleAuthors", ` +SELECT user_id FROM web_article_authors WHERE article_id = $1 ORDER BY user_id`) + + qDropArticleAuthors = register("DropArticleAuthors", ` +DELETE FROM web_article_authors WHERE article_id = $1 AND NOT (user_id = ANY($2))`) +) + +// An empty list means the caller had nothing to say, not that the page should +// lose the credit it has. +func (d *DB) SetArticleAuthors(ctx context.Context, articleID int64, authorIDs []int64, + userID *int64, at time.Time) (Revision, bool, error) { + + if len(authorIDs) == 0 { + return Revision{}, false, nil + } + + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, false, fmt.Errorf("begin authors of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + wanted, err := scanIDs(ctx, tx, qKnownUsers, authorIDs) + if err != nil { + return Revision{}, false, fmt.Errorf("look up authors of %d: %w", articleID, err) + } + if len(wanted) == 0 { + return Revision{}, false, nil + } + held, err := scanIDs(ctx, tx, qReadArticleAuthors, articleID) + if err != nil { + return Revision{}, false, fmt.Errorf("read authors of %d: %w", articleID, err) + } + + added := missingFrom(wanted, held) + removed := missingFrom(held, wanted) + if len(added) == 0 && len(removed) == 0 { + return Revision{}, false, nil + } + if _, err := tx.Exec(ctx, qDropArticleAuthors, articleID, wanted); err != nil { + return Revision{}, false, fmt.Errorf("drop authors of %d: %w", articleID, err) + } + for _, id := range added { + if _, err := tx.Exec(ctx, qInsertArticleAuthor, articleID, id); err != nil { + return Revision{}, false, fmt.Errorf("credit author of %d: %w", articleID, err) + } + } + + meta, err := json.Marshal(map[string]any{"added_authors": added, "removed_authors": removed}) + if err != nil { + return Revision{}, false, fmt.Errorf("encode authorship meta of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qLockArticleLog, articleID); err != nil { + return Revision{}, false, fmt.Errorf("lock article log %d: %w", articleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, articleID, userID, LogAuthorship, string(meta), "", at).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, false, fmt.Errorf("write revision of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, articleID, at); err != nil { + return Revision{}, false, fmt.Errorf("touch article %d: %w", articleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, false, fmt.Errorf("commit authors of %d: %w", articleID, err) + } + return rev, true, nil +} + +// The result is never nil. A nil slice reaches Postgres as NULL, and a delete +// guarded by NOT (x = ANY(NULL)) then clears nothing at all. +func scanIDs(ctx context.Context, tx pgx.Tx, sql string, arg any) ([]int64, error) { + rows, err := tx.Query(ctx, sql, arg) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []int64{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func missingFrom(want, have []int64) []int64 { + out := []int64{} + for _, id := range want { + if !slices.Contains(have, id) { + out = append(out, id) + } + } + return out +} + +var ( + qRenameArticle = register("RenameArticle", ` +UPDATE web_article SET category = $2, name = $3 WHERE id = $1`) + + qDropLinksFrom = register("DropLinksFrom", ` +DELETE FROM web_externallink WHERE link_from = $1 AND from_site_id = $2`) + + qMoveLinksFrom = register("MoveLinksFrom", ` +UPDATE web_externallink SET link_from = $2 WHERE link_from = $1 AND from_site_id = $3`) +) + +// What a page points at moves with it, but what points at the page does not. +// Everyone who linked to the old name keeps linking to the old name. +func (d *DB) RenameArticle(ctx context.Context, siteID, articleID int64, category, name, from string, + userID *int64, at time.Time) (Revision, error) { + + to := (&Article{Category: category, Name: name}).FullName() + + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("begin rename of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qRenameArticle, articleID, category, name); err != nil { + return Revision{}, fmt.Errorf("rename article %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qDropLinksFrom, to, siteID); err != nil { + return Revision{}, fmt.Errorf("drop links of %q: %w", to, err) + } + if _, err := tx.Exec(ctx, qMoveLinksFrom, from, to, siteID); err != nil { + return Revision{}, fmt.Errorf("move links of %q: %w", from, err) + } + + meta, err := json.Marshal(map[string]any{"name": to, "prev_name": from}) + if err != nil { + return Revision{}, fmt.Errorf("encode rename meta of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qLockArticleLog, articleID); err != nil { + return Revision{}, fmt.Errorf("lock article log %d: %w", articleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, articleID, userID, LogName, string(meta), "", at).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, fmt.Errorf("write revision of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, articleID, at); err != nil { + return Revision{}, fmt.Errorf("touch article %d: %w", articleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("commit rename of %d: %w", articleID, err) + } + return rev, nil +} diff --git a/internal/db/article_write_test.go b/internal/db/article_write_test.go new file mode 100644 index 00000000..ccb872ee --- /dev/null +++ b/internal/db/article_write_test.go @@ -0,0 +1,905 @@ +package db + +import ( + "context" + "encoding/json" + "os" + "testing" + "time" +) + +func writeTestDB(t *testing.T) *DB { + t.Helper() + dsn := os.Getenv(EnvWriteDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the write test", EnvWriteDSN) + } + conn, err := Open(context.Background(), dsn) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + t.Cleanup(conn.Close) + return conn +} + +func scratchArticle(t *testing.T, d *DB) int64 { + t.Helper() + ctx := context.Background() + name := "probe-write-" + time.Now().Format("20060102150405.000000") + var id int64 + err := d.pool.QueryRow(ctx, ` +INSERT INTO web_article (site_id, category, name, title, locked, created_at, updated_at, media_name) +VALUES ($3, '_default', $1, 'Probe', false, now(), now(), $2) +RETURNING id`, name, name, seedSiteID(t, d)).Scan(&id) + if err != nil { + t.Fatalf("insert scratch article err = %v, want nil", err) + } + t.Cleanup(func() { + clean := context.Background() + for _, sql := range []string{ + `DELETE FROM web_articlelogentry WHERE article_id = $1`, + `DELETE FROM web_articleversion WHERE article_id = $1`, + `DELETE FROM web_articlefavourite WHERE article_id = $1`, + `DELETE FROM web_forumpostlike WHERE post_id IN ( + SELECT p.id FROM web_forumpost p + JOIN web_forumthread t ON t.id = p.thread_id + WHERE t.article_id = $1)`, + `DELETE FROM web_forumpost WHERE thread_id IN ( + SELECT id FROM web_forumthread WHERE article_id = $1)`, + `DELETE FROM web_forumthread WHERE article_id = $1`, + `DELETE FROM web_article WHERE id = $1`, + } { + if _, err := d.pool.Exec(clean, sql, id); err != nil { + t.Errorf("clean up scratch article err = %v, want nil", err) + } + } + }) + return id +} + +func TestCreateArticleVersionNumbersFromZero(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchArticle(t, d) + at := time.Now().UTC().Truncate(time.Second) + + first, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "one", Kind: LogNew, Title: "Probe", At: at, + }) + if err != nil { + t.Fatalf("CreateArticleVersion(new) err = %v, want nil", err) + } + if first.RevNumber != 0 { + t.Errorf("CreateArticleVersion(new).RevNumber = %d, want 0", first.RevNumber) + } + + second, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "two", Kind: LogSource, Comment: "fixed a typo", At: at.Add(time.Second), + }) + if err != nil { + t.Fatalf("CreateArticleVersion(source) err = %v, want nil", err) + } + if second.RevNumber != 1 { + t.Errorf("CreateArticleVersion(source).RevNumber = %d, want 1", second.RevNumber) + } + if second.VersionID == first.VersionID { + t.Errorf("CreateArticleVersion(source).VersionID = %d, want a new one", second.VersionID) + } + + source, err := d.LatestSource(ctx, id) + if err != nil { + t.Fatalf("LatestSource() err = %v, want nil", err) + } + if source != "two" { + t.Errorf("LatestSource() = %q, want %q", source, "two") + } +} + +func TestCreateArticleVersionMetaNamesTheVersion(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchArticle(t, d) + at := time.Now().UTC().Truncate(time.Second) + + rev, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "one", Kind: LogNew, Title: "Probe", At: at, + }) + if err != nil { + t.Fatalf("CreateArticleVersion(new) err = %v, want nil", err) + } + + var kind, comment string + var raw []byte + err = d.pool.QueryRow(ctx, ` +SELECT type, comment, meta FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 0`, + id).Scan(&kind, &comment, &raw) + if err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + if kind != LogNew { + t.Errorf("revision type = %q, want %q", kind, LogNew) + } + + var meta map[string]any + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta err = %v, want nil", err) + } + if got, want := meta["version_id"], float64(rev.VersionID); got != want { + t.Errorf("meta version_id = %v, want %v", got, want) + } + if got, want := meta["title"], "Probe"; got != want { + t.Errorf("meta title = %v, want %q", got, want) + } +} + +func TestCreateArticleVersionKeepsTheTitleOffAnEdit(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchArticle(t, d) + at := time.Now().UTC().Truncate(time.Second) + + if _, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "one", Kind: LogNew, Title: "Probe", At: at, + }); err != nil { + t.Fatalf("CreateArticleVersion(new) err = %v, want nil", err) + } + if _, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "two", Kind: LogSource, Title: "Probe", At: at, + }); err != nil { + t.Fatalf("CreateArticleVersion(source) err = %v, want nil", err) + } + + var raw []byte + err := d.pool.QueryRow(ctx, ` +SELECT meta FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 1`, id).Scan(&raw) + if err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + var meta map[string]any + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta err = %v, want nil", err) + } + if _, ok := meta["title"]; ok { + t.Errorf("meta of an edit carries title = %v, want it absent", meta["title"]) + } +} + +func TestCreateArticleVersionTouchesTheArticle(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchArticle(t, d) + at := time.Date(2030, 4, 5, 6, 7, 8, 0, time.UTC) + + if _, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "one", Kind: LogNew, At: at, + }); err != nil { + t.Fatalf("CreateArticleVersion(new) err = %v, want nil", err) + } + + var updated time.Time + if err := d.pool.QueryRow(ctx, `SELECT updated_at FROM web_article WHERE id = $1`, id).Scan(&updated); err != nil { + t.Fatalf("read article err = %v, want nil", err) + } + if !updated.Equal(at) { + t.Errorf("article updated_at = %v, want %v", updated, at) + } +} + +func linkSet(t *testing.T, d *DB, from string) map[string]bool { + t.Helper() + rows, err := d.pool.Query(context.Background(), ` +SELECT link_type, link_to FROM web_externallink WHERE link_from = $1`, from) + if err != nil { + t.Fatalf("read links err = %v, want nil", err) + } + defer rows.Close() + + out := map[string]bool{} + for rows.Next() { + var kind, to string + if err := rows.Scan(&kind, &to); err != nil { + t.Fatalf("scan link err = %v, want nil", err) + } + out[kind+" "+to] = true + } + return out +} + +func scratchLinkOwner(t *testing.T, d *DB) string { + t.Helper() + from := "probe-links-" + time.Now().Format("20060102150405.000000") + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM web_externallink WHERE link_from = $1`, from); err != nil { + t.Errorf("clean up links err = %v, want nil", err) + } + }) + return from +} + +func TestReplaceArticleLinksWritesBothKinds(t *testing.T) { + d := writeTestDB(t) + from := scratchLinkOwner(t, d) + + err := d.ReplaceArticleLinks(context.Background(), seedSiteID(t, d), from, []ArticleLink{ + {To: "component:box", Kind: LinkInclude}, + {To: "scp-173", Kind: LinkPlain}, + }) + if err != nil { + t.Fatalf("ReplaceArticleLinks() err = %v, want nil", err) + } + + got := linkSet(t, d, from) + for _, want := range []string{"include component:box", "link scp-173"} { + if !got[want] { + t.Errorf("links of %q missing %q, got %v", from, want, got) + } + } + if len(got) != 2 { + t.Errorf("len(links) = %d, want 2", len(got)) + } +} + +func TestReplaceArticleLinksDropsWhatIsGone(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + from := scratchLinkOwner(t, d) + + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), from, []ArticleLink{{To: "old", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks(first) err = %v, want nil", err) + } + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), from, []ArticleLink{{To: "new", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks(second) err = %v, want nil", err) + } + + got := linkSet(t, d, from) + if got["link old"] { + t.Errorf("links of %q still carry the dropped one, got %v", from, got) + } + if !got["link new"] { + t.Errorf("links of %q missing %q, got %v", from, "link new", got) + } +} + +func TestReplaceArticleLinksCollapsesRepeats(t *testing.T) { + d := writeTestDB(t) + from := scratchLinkOwner(t, d) + + err := d.ReplaceArticleLinks(context.Background(), seedSiteID(t, d), from, []ArticleLink{ + {To: "component:box", Kind: LinkInclude}, + {To: "component:box", Kind: LinkInclude}, + {To: "component:box", Kind: LinkPlain}, + }) + if err != nil { + t.Fatalf("ReplaceArticleLinks() err = %v, want nil", err) + } + if got := linkSet(t, d, from); len(got) != 2 { + t.Errorf("len(links) = %d, want 2, got %v", len(got), got) + } +} + +func TestReplaceArticleLinksEmptiesTheSet(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + from := scratchLinkOwner(t, d) + + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), from, []ArticleLink{{To: "old", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks(first) err = %v, want nil", err) + } + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), from, nil); err != nil { + t.Fatalf("ReplaceArticleLinks(none) err = %v, want nil", err) + } + if got := linkSet(t, d, from); len(got) != 0 { + t.Errorf("len(links) = %d, want 0, got %v", len(got), got) + } +} + +func dropArticle(t *testing.T, d *DB, id int64) { + t.Helper() + t.Cleanup(func() { + clean := context.Background() + for _, sql := range []string{ + `DELETE FROM web_articlelogentry WHERE article_id = $1`, + `DELETE FROM web_articleversion WHERE article_id = $1`, + `DELETE FROM web_article_authors WHERE article_id = $1`, + `DELETE FROM web_article WHERE id = $1`, + } { + if _, err := d.pool.Exec(clean, sql, id); err != nil { + t.Errorf("clean up article err = %v, want nil", err) + } + } + }) +} + +func TestCreateArticleCreditsTheAuthor(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + + var author int64 + if err := d.pool.QueryRow(ctx, `SELECT id FROM web_user ORDER BY id LIMIT 1`).Scan(&author); err != nil { + t.Fatalf("read a user err = %v, want nil", err) + } + name := "probe-new-" + time.Now().Format("20060102150405.000000") + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", name, "Probe", &author, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + var authors int + if err := d.pool.QueryRow(ctx, + `SELECT count(*) FROM web_article_authors WHERE article_id = $1 AND user_id = $2`, + id, author).Scan(&authors); err != nil { + t.Fatalf("read authors err = %v, want nil", err) + } + if authors != 1 { + t.Errorf("count(authors) = %d, want 1", authors) + } +} + +func TestCreateArticleWithoutAnAuthor(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + name := "probe-new-" + time.Now().Format("20060102150405.000000") + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", name, "Probe", nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + article, err := d.ArticleByID(ctx, seedSiteID(t, d), id) + if err != nil { + t.Fatalf("ArticleByID() err = %v, want nil", err) + } + if article.Title != "Probe" { + t.Errorf("ArticleByID().Title = %q, want %q", article.Title, "Probe") + } + if article.Locked { + t.Error("ArticleByID().Locked = true, want false") + } +} + +func TestCreateArticleNamesTheMediaDirectoryApart(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + stamp := time.Now().Format("20060102150405.000000") + + first, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", "probe-media-a-"+stamp, "A", nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle(a) err = %v, want nil", err) + } + dropArticle(t, d, first) + second, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", "probe-media-b-"+stamp, "B", nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle(b) err = %v, want nil", err) + } + dropArticle(t, d, second) + + var a, b string + if err := d.pool.QueryRow(ctx, `SELECT media_name FROM web_article WHERE id = $1`, first).Scan(&a); err != nil { + t.Fatalf("read media_name err = %v, want nil", err) + } + if err := d.pool.QueryRow(ctx, `SELECT media_name FROM web_article WHERE id = $1`, second).Scan(&b); err != nil { + t.Fatalf("read media_name err = %v, want nil", err) + } + if a == b { + t.Errorf("media_name of two articles = %q for both, want them apart", a) + } + if len(a) != 36 { + t.Errorf("len(media_name) = %d, want 36", len(a)) + } +} + +func TestSetArticleParentRecordsTheMove(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + at := time.Now().UTC().Truncate(time.Second) + stamp := time.Now().Format("20060102150405.000000") + + parent, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", "probe-parent-"+stamp, "Parent", nil, at) + if err != nil { + t.Fatalf("CreateArticle(parent) err = %v, want nil", err) + } + dropArticle(t, d, parent) + child, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", "probe-child-"+stamp, "Child", nil, at) + if err != nil { + t.Fatalf("CreateArticle(child) err = %v, want nil", err) + } + dropArticle(t, d, child) + + meta := `{"parent": "probe-parent", "prev_parent": null, "parent_id": null, "prev_parent_id": null}` + rev, err := d.SetArticleParent(ctx, child, &parent, nil, meta, at) + if err != nil { + t.Fatalf("SetArticleParent() err = %v, want nil", err) + } + if rev.RevNumber != 0 { + t.Errorf("SetArticleParent().RevNumber = %d, want 0", rev.RevNumber) + } + + article, err := d.ArticleByID(ctx, seedSiteID(t, d), child) + if err != nil { + t.Fatalf("ArticleByID() err = %v, want nil", err) + } + if article.ParentID == nil || *article.ParentID != parent { + t.Errorf("ArticleByID().ParentID = %v, want %d", article.ParentID, parent) + } + + var kind string + if err := d.pool.QueryRow(ctx, + `SELECT type FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 0`, + child).Scan(&kind); err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + if kind != LogParent { + t.Errorf("revision type = %q, want %q", kind, LogParent) + } +} + +func TestSubscribeToArticleOnlyOnce(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + + var user int64 + if err := d.pool.QueryRow(ctx, `SELECT id FROM web_user ORDER BY id LIMIT 1`).Scan(&user); err != nil { + t.Fatalf("read a user err = %v, want nil", err) + } + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", + "probe-sub-"+time.Now().Format("20060102150405.000000"), "Probe", nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM web_usernotificationsubscription WHERE article_id = $1`, id); err != nil { + t.Errorf("clean up subscription err = %v, want nil", err) + } + }) + + for i := 0; i < 2; i++ { + if err := d.SubscribeToArticle(ctx, user, id); err != nil { + t.Fatalf("SubscribeToArticle(%d) err = %v, want nil", i, err) + } + } + + var rows int + if err := d.pool.QueryRow(ctx, + `SELECT count(*) FROM web_usernotificationsubscription WHERE article_id = $1 AND subscriber_id = $2`, + id, user).Scan(&rows); err != nil { + t.Fatalf("count subscriptions err = %v, want nil", err) + } + if rows != 1 { + t.Errorf("count(subscriptions) = %d, want 1", rows) + } +} + +func TestUpdateArticleTitleRecordsWhatItWas(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + at := time.Now().UTC().Truncate(time.Second) + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", + "probe-title-"+time.Now().Format("20060102150405.000000"), "Before", nil, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + rev, err := d.UpdateArticleTitle(ctx, id, "After", nil, at) + if err != nil { + t.Fatalf("UpdateArticleTitle() err = %v, want nil", err) + } + if rev.RevNumber != 0 { + t.Errorf("UpdateArticleTitle().RevNumber = %d, want 0", rev.RevNumber) + } + + article, err := d.ArticleByID(ctx, seedSiteID(t, d), id) + if err != nil { + t.Fatalf("ArticleByID() err = %v, want nil", err) + } + if article.Title != "After" { + t.Errorf("ArticleByID().Title = %q, want %q", article.Title, "After") + } + + var kind string + var raw []byte + if err := d.pool.QueryRow(ctx, + `SELECT type, meta FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 0`, + id).Scan(&kind, &raw); err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + if kind != LogTitle { + t.Errorf("revision type = %q, want %q", kind, LogTitle) + } + var meta map[string]any + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta err = %v, want nil", err) + } + if got, want := meta["prev_title"], "Before"; got != want { + t.Errorf("meta prev_title = %v, want %q", got, want) + } + if got, want := meta["title"], "After"; got != want { + t.Errorf("meta title = %v, want %q", got, want) + } +} + +func TestSetArticleLockLeavesNoRevision(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", + "probe-lock-"+time.Now().Format("20060102150405.000000"), "Probe", nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + if err := d.SetArticleLock(ctx, id, true); err != nil { + t.Fatalf("SetArticleLock() err = %v, want nil", err) + } + article, err := d.ArticleByID(ctx, seedSiteID(t, d), id) + if err != nil { + t.Fatalf("ArticleByID() err = %v, want nil", err) + } + if !article.Locked { + t.Error("ArticleByID().Locked = false, want true") + } + + var revisions int + if err := d.pool.QueryRow(ctx, + `SELECT count(*) FROM web_articlelogentry WHERE article_id = $1`, id).Scan(&revisions); err != nil { + t.Fatalf("count revisions err = %v, want nil", err) + } + if revisions != 0 { + t.Errorf("count(revisions) after locking = %d, want 0", revisions) + } +} + +func twoUsers(t *testing.T, d *DB) (int64, int64) { + t.Helper() + rows, err := d.pool.Query(context.Background(), `SELECT id FROM web_user ORDER BY id LIMIT 2`) + if err != nil { + t.Fatalf("read users err = %v, want nil", err) + } + defer rows.Close() + + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + t.Fatalf("scan user err = %v, want nil", err) + } + ids = append(ids, id) + } + if len(ids) < 2 { + t.Skip("the database holds fewer than two users") + } + return ids[0], ids[1] +} + +func authorsOf(t *testing.T, d *DB, id int64) []int64 { + t.Helper() + rows, err := d.pool.Query(context.Background(), + `SELECT user_id FROM web_article_authors WHERE article_id = $1 ORDER BY user_id`, id) + if err != nil { + t.Fatalf("read authors err = %v, want nil", err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var user int64 + if err := rows.Scan(&user); err != nil { + t.Fatalf("scan author err = %v, want nil", err) + } + out = append(out, user) + } + return out +} + +func TestSetArticleAuthorsReplacesTheCredit(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + first, second := twoUsers(t, d) + at := time.Now().UTC().Truncate(time.Second) + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", + "probe-authors-"+time.Now().Format("20060102150405.000000"), "Probe", &first, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + rev, wrote, err := d.SetArticleAuthors(ctx, id, []int64{second}, nil, at) + if err != nil { + t.Fatalf("SetArticleAuthors() err = %v, want nil", err) + } + if !wrote { + t.Fatal("SetArticleAuthors() wrote no revision, want one") + } + if rev.RevNumber != 0 { + t.Errorf("SetArticleAuthors().RevNumber = %d, want 0", rev.RevNumber) + } + + got := authorsOf(t, d, id) + if len(got) != 1 || got[0] != second { + t.Errorf("authors = %v, want [%d]", got, second) + } + + var raw []byte + if err := d.pool.QueryRow(ctx, + `SELECT meta FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 0`, id).Scan(&raw); err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + var meta map[string][]int64 + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta err = %v, want nil", err) + } + if len(meta["added_authors"]) != 1 || meta["added_authors"][0] != second { + t.Errorf("meta added_authors = %v, want [%d]", meta["added_authors"], second) + } + if len(meta["removed_authors"]) != 1 || meta["removed_authors"][0] != first { + t.Errorf("meta removed_authors = %v, want [%d]", meta["removed_authors"], first) + } +} + +func TestSetArticleAuthorsKeepsQuietWhenNothingMoves(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + first, _ := twoUsers(t, d) + at := time.Now().UTC().Truncate(time.Second) + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", + "probe-samecredit-"+time.Now().Format("20060102150405.000000"), "Probe", &first, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + _, wrote, err := d.SetArticleAuthors(ctx, id, []int64{first}, nil, at) + if err != nil { + t.Fatalf("SetArticleAuthors() err = %v, want nil", err) + } + if wrote { + t.Error("SetArticleAuthors() wrote a revision, want none") + } +} + +func TestSetArticleAuthorsIgnoresAnEmptyList(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + first, _ := twoUsers(t, d) + at := time.Now().UTC().Truncate(time.Second) + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", + "probe-nocredit-"+time.Now().Format("20060102150405.000000"), "Probe", &first, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + + if _, wrote, err := d.SetArticleAuthors(ctx, id, nil, nil, at); err != nil || wrote { + t.Errorf("SetArticleAuthors(nil) = %v, %v, want false, nil", wrote, err) + } + if got := authorsOf(t, d, id); len(got) != 1 || got[0] != first { + t.Errorf("authors = %v, want [%d]", got, first) + } +} + +func TestRenameArticleTakesItsLinksAlong(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + at := time.Now().UTC().Truncate(time.Second) + stamp := time.Now().Format("20060102150405.000000") + from := "probe-from-" + stamp + to := "probe-to-" + stamp + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", from, "Probe", nil, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + t.Cleanup(func() { + for _, name := range []string{from, to} { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM web_externallink WHERE link_from = $1`, name); err != nil { + t.Errorf("clean up links err = %v, want nil", err) + } + } + }) + + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), from, []ArticleLink{{To: "main", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks() err = %v, want nil", err) + } + + rev, err := d.RenameArticle(ctx, seedSiteID(t, d), id, "_default", to, from, nil, at) + if err != nil { + t.Fatalf("RenameArticle() err = %v, want nil", err) + } + if rev.RevNumber != 0 { + t.Errorf("RenameArticle().RevNumber = %d, want 0", rev.RevNumber) + } + + article, err := d.ArticleByID(ctx, seedSiteID(t, d), id) + if err != nil { + t.Fatalf("ArticleByID() err = %v, want nil", err) + } + if article.Name != to { + t.Errorf("ArticleByID().Name = %q, want %q", article.Name, to) + } + if got := linkSet(t, d, to); !got["link main"] { + t.Errorf("links of %q = %v, want them moved over", to, got) + } + if got := linkSet(t, d, from); len(got) != 0 { + t.Errorf("links of %q = %v, want none left", from, got) + } + + var kind string + var raw []byte + if err := d.pool.QueryRow(ctx, + `SELECT type, meta FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 0`, + id).Scan(&kind, &raw); err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + if kind != LogName { + t.Errorf("revision type = %q, want %q", kind, LogName) + } + var meta map[string]string + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta err = %v, want nil", err) + } + if meta["prev_name"] != from || meta["name"] != to { + t.Errorf("meta = %v, want prev_name %q and name %q", meta, from, to) + } +} + +func TestRenameArticleClearsWhatSatUnderTheNewName(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + at := time.Now().UTC().Truncate(time.Second) + stamp := time.Now().Format("20060102150405.000000") + from := "probe-old-" + stamp + to := "probe-new-" + stamp + + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", from, "Probe", nil, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + t.Cleanup(func() { + for _, name := range []string{from, to} { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM web_externallink WHERE link_from = $1`, name); err != nil { + t.Errorf("clean up links err = %v, want nil", err) + } + } + }) + + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), from, []ArticleLink{{To: "kept", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks(from) err = %v, want nil", err) + } + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), to, []ArticleLink{{To: "stale", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks(to) err = %v, want nil", err) + } + + if _, err := d.RenameArticle(ctx, seedSiteID(t, d), id, "_default", to, from, nil, at); err != nil { + t.Fatalf("RenameArticle() err = %v, want nil", err) + } + got := linkSet(t, d, to) + if got["link stale"] { + t.Errorf("links of %q still carry the stale one, got %v", to, got) + } + if !got["link kept"] { + t.Errorf("links of %q missing the moved one, got %v", to, got) + } +} + +func TestDeleteArticleTakesEverythingThatPointsAtIt(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + at := time.Now().UTC().Truncate(time.Second) + stamp := time.Now().Format("20060102150405.000000") + name := "probe-doomed-" + stamp + + var author int64 + if err := d.pool.QueryRow(ctx, `SELECT id FROM web_user ORDER BY id LIMIT 1`).Scan(&author); err != nil { + t.Fatalf("read a user err = %v, want nil", err) + } + id, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", name, "Probe", &author, at) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + if _, err := d.CreateArticleVersion(ctx, VersionWrite{ + ArticleID: id, Source: "body", Kind: LogNew, Title: "Probe", At: at, + }); err != nil { + t.Fatalf("CreateArticleVersion() err = %v, want nil", err) + } + if err := d.SubscribeToArticle(ctx, author, id); err != nil { + t.Fatalf("SubscribeToArticle() err = %v, want nil", err) + } + if err := d.ReplaceArticleLinks(ctx, seedSiteID(t, d), name, []ArticleLink{{To: "main", Kind: LinkPlain}}); err != nil { + t.Fatalf("ReplaceArticleLinks() err = %v, want nil", err) + } + + child, err := d.CreateArticle(ctx, seedSiteID(t, d), "_default", "probe-orphan-"+stamp, "Child", nil, at) + if err != nil { + t.Fatalf("CreateArticle(child) err = %v, want nil", err) + } + dropArticle(t, d, child) + if _, err := d.SetArticleParent(ctx, child, &id, nil, + `{"parent": "x", "prev_parent": null, "parent_id": null, "prev_parent_id": null}`, at); err != nil { + t.Fatalf("SetArticleParent() err = %v, want nil", err) + } + + if err := d.DeleteArticle(ctx, seedSiteID(t, d), id, name); err != nil { + t.Fatalf("DeleteArticle() err = %v, want nil", err) + } + + for _, probe := range []struct { + what string + sql string + }{ + {"article", `SELECT count(*) FROM web_article WHERE id = $1`}, + {"versions", `SELECT count(*) FROM web_articleversion WHERE article_id = $1`}, + {"revisions", `SELECT count(*) FROM web_articlelogentry WHERE article_id = $1`}, + {"authors", `SELECT count(*) FROM web_article_authors WHERE article_id = $1`}, + {"subscriptions", `SELECT count(*) FROM web_usernotificationsubscription WHERE article_id = $1`}, + } { + var left int + if err := d.pool.QueryRow(ctx, probe.sql, id).Scan(&left); err != nil { + t.Fatalf("count %s err = %v, want nil", probe.what, err) + } + if left != 0 { + t.Errorf("count(%s) after deleting = %d, want 0", probe.what, left) + } + } + if got := linkSet(t, d, name); len(got) != 0 { + t.Errorf("links of %q after deleting = %v, want none", name, got) + } + + orphan, err := d.ArticleByID(ctx, seedSiteID(t, d), child) + if err != nil { + t.Fatalf("ArticleByID(child) err = %v, want nil", err) + } + if orphan.ParentID != nil { + t.Errorf("child ParentID after deleting the parent = %v, want nil", orphan.ParentID) + } +} + +func TestArticleLinksAreReadBackPerSite(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + here := seedSiteID(t, d) + from := "probe-links-" + time.Now().Format("20060102150405.000000") + to := from + "-target" + + if err := d.ReplaceArticleLinks(ctx, here, from, []ArticleLink{ + {To: to, Kind: LinkInclude, ToSiteID: &here}, + }); err != nil { + t.Fatalf("ReplaceArticleLinks() err = %v, want nil", err) + } + t.Cleanup(func() { + d.pool.Exec(context.Background(), `DELETE FROM web_externallink WHERE link_from = $1`, from) + }) + + found, err := d.LinksTo(ctx, here, to) + if err != nil { + t.Fatalf("LinksTo(here) err = %v, want nil", err) + } + if len(found) != 1 { + t.Fatalf("len(LinksTo(here, %q)) = %d, want 1", to, len(found)) + } + if found[0].From != from { + t.Errorf("LinksTo(here, %q)[0].From = %q, want %q", to, found[0].From, from) + } + + elsewhere, err := d.LinksTo(ctx, here+1000, to) + if err != nil { + t.Fatalf("LinksTo(other) err = %v, want nil", err) + } + if len(elsewhere) != 0 { + t.Errorf("len(LinksTo(other site, %q)) = %d, want 0", to, len(elsewhere)) + } +} diff --git a/internal/db/backup.go b/internal/db/backup.go new file mode 100644 index 00000000..72d4f780 --- /dev/null +++ b/internal/db/backup.go @@ -0,0 +1,276 @@ +package db + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/jackc/pgx/v5" +) + +// The migration ledger is rebuilt by replaying the migrations a restore names, +// so carrying its rows would fight with that. +const LedgerTable = "pwikit_migration" + +var qBackupTables = register("BackupTables", ` +SELECT c.relname +FROM pg_class c +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' AND c.relkind = 'r' +ORDER BY c.relname`) + +// These take a connection rather than the pool, because a backup streams COPY +// and rebuilds the schema inside one transaction. +func BackupTables(ctx context.Context, conn *pgx.Conn) ([]string, error) { + rows, err := conn.Query(ctx, qBackupTables) + if err != nil { + return nil, fmt.Errorf("list tables: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + if name != LedgerTable { + out = append(out, name) + } + } + return out, rows.Err() +} + +var qServerVersion = register("ServerVersion", `SELECT current_setting('server_version_num')::int`) + +func ServerVersion(ctx context.Context, conn *pgx.Conn) (int, error) { + var n int + if err := conn.QueryRow(ctx, qServerVersion).Scan(&n); err != nil { + return 0, fmt.Errorf("read the postgres version: %w", err) + } + return n, nil +} + +var qAppliedMigrations = register("AppliedMigrations", + `SELECT name FROM `+LedgerTable+` ORDER BY name`) + +func AppliedMigrations(ctx context.Context, conn *pgx.Conn) ([]string, error) { + rows, err := conn.Query(ctx, qAppliedMigrations) + if err != nil { + return nil, fmt.Errorf("read the applied migrations: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +var qOtherConnections = register("OtherConnections", ` +SELECT count(*) FROM pg_stat_activity +WHERE datname = current_database() AND pid <> pg_backend_pid()`) + +func OtherConnections(ctx context.Context, conn *pgx.Conn) (int, error) { + var n int + if err := conn.QueryRow(ctx, qOtherConnections).Scan(&n); err != nil { + return 0, fmt.Errorf("look for other connections: %w", err) + } + return n, nil +} + +var qDatabaseExists = register("DatabaseExists", `SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)`) + +func EnsureDatabase(ctx context.Context, conn *pgx.Conn, name string) error { + var found bool + if err := conn.QueryRow(ctx, qDatabaseExists, name).Scan(&found); err != nil { + return fmt.Errorf("look for database %q: %w", name, err) + } + if found { + return nil + } + if _, err := conn.Exec(ctx, `CREATE DATABASE `+QuoteName(name)+` ENCODING 'UTF8' TEMPLATE template0`); err != nil { + return fmt.Errorf("create database %q: %w", name, err) + } + return nil +} + +func NonEmptyTables(ctx context.Context, conn *pgx.Conn, tables []string) ([]string, error) { + var out []string + for _, name := range tables { + var any bool + if err := conn.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM `+QuoteName(name)+`)`).Scan(&any); err != nil { + return nil, fmt.Errorf("look inside %s: %w", name, err) + } + if any { + out = append(out, name) + } + } + return out, nil +} + +var qSiteSlugExists = register("SiteSlugExists", `SELECT EXISTS (SELECT 1 FROM web_site WHERE slug = $1)`) + +func SiteSlugExists(ctx context.Context, conn *pgx.Conn, slug string) (bool, error) { + var found bool + if err := conn.QueryRow(ctx, qSiteSlugExists, slug).Scan(&found); err != nil { + return false, fmt.Errorf("look for site %q: %w", slug, err) + } + return found, nil +} + +func CopyOut(ctx context.Context, tx pgx.Tx, w io.Writer, table, query string) (int64, error) { + source := `COPY ` + QuoteName(table) + ` TO STDOUT` + if query != "" { + source = `COPY (` + query + `) TO STDOUT` + } + tag, err := tx.Conn().PgConn().CopyTo(ctx, w, source) + if err != nil { + return 0, fmt.Errorf("read %s: %w", table, err) + } + return tag.RowsAffected(), nil +} + +func CopyIn(ctx context.Context, tx pgx.Tx, r io.Reader, table string) (int64, error) { + tag, err := tx.Conn().PgConn().CopyFrom(ctx, r, `COPY `+QuoteName(table)+` FROM STDIN`) + if err != nil { + return 0, fmt.Errorf("load %s: %w", table, err) + } + return tag.RowsAffected(), nil +} + +// CopyTo takes no arguments, so a value the query needs is put into the text +// after Postgres has quoted it. +func QuoteLiteral(ctx context.Context, tx pgx.Tx, value string) (string, error) { + var quoted string + if err := tx.QueryRow(ctx, `SELECT quote_literal($1::text)`, value).Scan("ed); err != nil { + return "", err + } + return quoted, nil +} + +func ResetSchema(ctx context.Context, tx pgx.Tx) error { + if _, err := tx.Exec(ctx, `DROP SCHEMA public CASCADE`); err != nil { + return fmt.Errorf("clear the database: %w", err) + } + if _, err := tx.Exec(ctx, `CREATE SCHEMA public`); err != nil { + return fmt.Errorf("clear the database: %w", err) + } + return nil +} + +func TruncateAll(ctx context.Context, tx pgx.Tx, tables []string) error { + if len(tables) == 0 { + return nil + } + quoted := make([]string, len(tables)) + for i, name := range tables { + quoted[i] = QuoteName(name) + } + if _, err := tx.Exec(ctx, `TRUNCATE `+strings.Join(quoted, ", ")); err != nil { + return fmt.Errorf("clear the seeded rows: %w", err) + } + return nil +} + +type ForeignKey struct { + Table string + Name string + Def string +} + +// LiftForeignKeys takes the references off so rows can arrive in any order. Two +// of them point at each other, so no load order satisfies every one. +func LiftForeignKeys(ctx context.Context, tx pgx.Tx) ([]ForeignKey, error) { + rows, err := tx.Query(ctx, ` +SELECT c.relname, k.conname, pg_get_constraintdef(k.oid) +FROM pg_constraint k +JOIN pg_class c ON c.oid = k.conrelid +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE k.contype = 'f' AND n.nspname = 'public' +ORDER BY c.relname, k.conname`) + if err != nil { + return nil, fmt.Errorf("list the references: %w", err) + } + var keys []ForeignKey + for rows.Next() { + var k ForeignKey + if err := rows.Scan(&k.Table, &k.Name, &k.Def); err != nil { + rows.Close() + return nil, err + } + keys = append(keys, k) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + for _, k := range keys { + if _, err := tx.Exec(ctx, `ALTER TABLE `+QuoteName(k.Table)+` DROP CONSTRAINT `+QuoteName(k.Name)); err != nil { + return nil, fmt.Errorf("set aside %s: %w", k.Name, err) + } + } + return keys, nil +} + +// Putting them back checks every row in bulk rather than a trigger at a time. +func RestoreForeignKeys(ctx context.Context, tx pgx.Tx, keys []ForeignKey) error { + for _, k := range keys { + sql := `ALTER TABLE ` + QuoteName(k.Table) + ` ADD CONSTRAINT ` + QuoteName(k.Name) + ` ` + k.Def + if _, err := tx.Exec(ctx, sql); err != nil { + return fmt.Errorf("the restored rows break %s on %s: %w", k.Name, k.Table, err) + } + } + return nil +} + +// The rows carry their own ids, so every identity column has to be told where +// to carry on from or the next insert collides with row one. +func ResetIdentities(ctx context.Context, tx pgx.Tx, tables map[string]bool) error { + rows, err := tx.Query(ctx, ` +SELECT c.relname, a.attname +FROM pg_attribute a +JOIN pg_class c ON c.oid = a.attrelid +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' AND c.relkind = 'r' AND a.attidentity <> ''`) + if err != nil { + return fmt.Errorf("list the identity columns: %w", err) + } + type column struct{ table, name string } + var found []column + for rows.Next() { + var c column + if err := rows.Scan(&c.table, &c.name); err != nil { + rows.Close() + return err + } + found = append(found, c) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + for _, c := range found { + if !tables[c.table] { + continue + } + sql := fmt.Sprintf( + `SELECT setval(pg_get_serial_sequence('%s', '%s'), coalesce(max(%s), 0) + 1, false) FROM %s`, + c.table, c.name, QuoteName(c.name), QuoteName(c.table)) + if _, err := tx.Exec(ctx, sql); err != nil { + return fmt.Errorf("restart the numbering of %s: %w", c.table, err) + } + } + return nil +} + +func QuoteName(name string) string { + return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` +} diff --git a/internal/db/backup_scope.go b/internal/db/backup_scope.go new file mode 100644 index 00000000..f4b6c06a --- /dev/null +++ b/internal/db/backup_scope.go @@ -0,0 +1,257 @@ +package db + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5" +) + +// how says what a table holds when one site is taken out on its own. +type how int + +const ( + // owned rows belong to the site and come along, narrowed by where. + owned how = iota + // shared rows are the same on every instance, so the whole table comes. + shared + // people is the slice of accounts the exported rows point at. + people + // left rows belong to the operator or to users rather than to the site, + // and stay behind. + left +) + +type rule struct { + how how + where string + // why is printed by the ratchet test when a table has no rule yet, and + // read by anyone auditing what a site export hands over. + why string +} + +// The names bound before each query. Written once here so a rule reads as one +// line rather than as a paragraph of subqueries. +const scopeCTE = ` +WITH s AS (SELECT id FROM web_site WHERE slug = $1), + art AS (SELECT id FROM web_article WHERE site_id = (SELECT id FROM s)), + cat AS (SELECT id FROM web_category WHERE site_id = (SELECT id FROM s)), + rol AS (SELECT id FROM web_role WHERE site_id = (SELECT id FROM s)), + sec AS (SELECT id FROM web_forumsection WHERE site_id = (SELECT id FROM s)), + fcat AS (SELECT id FROM web_forumcategory WHERE section_id IN (SELECT id FROM sec)), + thr AS (SELECT id FROM web_forumthread WHERE site_id = (SELECT id FROM s)), + pst AS (SELECT id FROM web_forumpost WHERE thread_id IN (SELECT id FROM thr)), + ovr AS (SELECT rolepermissionsoverride_id AS id FROM web_category_permissions_override + WHERE category_id IN (SELECT id FROM cat)) +` + +// peopleQuery gathers every account the exported rows point at. Nothing decides +// up front who counts; the references decide, which is why no key can dangle. +const peopleQuery = scopeCTE + `, + who AS ( + SELECT user_id AS id FROM web_article_authors WHERE article_id IN (SELECT id FROM art) + UNION SELECT user_id FROM web_articlefavourite WHERE article_id IN (SELECT id FROM art) + UNION SELECT user_id FROM web_articlelogentry WHERE article_id IN (SELECT id FROM art) + UNION SELECT user_id FROM web_vote WHERE article_id IN (SELECT id FROM art) + UNION SELECT author_id FROM web_file WHERE article_id IN (SELECT id FROM art) + UNION SELECT deleted_by_id FROM web_file WHERE article_id IN (SELECT id FROM art) + UNION SELECT author_id FROM web_forumthread WHERE site_id = (SELECT id FROM s) + UNION SELECT author_id FROM web_forumpost WHERE thread_id IN (SELECT id FROM thr) + UNION SELECT author_id FROM web_forumpostversion WHERE post_id IN (SELECT id FROM pst) + UNION SELECT user_id FROM web_forumpostlike WHERE post_id IN (SELECT id FROM pst) + UNION SELECT subscriber_id FROM web_usernotificationsubscription + WHERE article_id IN (SELECT id FROM art) OR forum_thread_id IN (SELECT id FROM thr) + UNION SELECT user_id FROM web_user_roles WHERE role_id IN (SELECT id FROM rol) + UNION SELECT created_by_id FROM web_invitelink WHERE site_id = (SELECT id FROM s) + UNION SELECT target_id FROM web_invitelink WHERE site_id = (SELECT id FROM s) + UNION SELECT reporter_id FROM web_userreport WHERE site_id = (SELECT id FROM s) + UNION SELECT reported_id FROM web_userreport WHERE site_id = (SELECT id FROM s) + UNION SELECT reviewed_by_id FROM web_userreport WHERE site_id = (SELECT id FROM s) + UNION SELECT author_id FROM web_userticket WHERE site_id = (SELECT id FROM s) + UNION SELECT reviewed_by_id FROM web_userticket WHERE site_id = (SELECT id FROM s) + UNION SELECT user_id FROM pwikit_admin_log WHERE site_id = (SELECT id FROM s) + ) +SELECT id FROM who WHERE id IS NOT NULL` + +// A column that names something staying behind cannot travel as it is. +var swaps = map[string]map[string]string{ + "web_externallink": { + "to_site_id": `CASE WHEN t.to_site_id = (SELECT id FROM s) THEN t.to_site_id END`, + }, +} + +var rules = map[string]rule{ + "web_site": {owned, `id = (SELECT id FROM s)`, ""}, + "web_settings": {owned, `site_id = (SELECT id FROM s)`, ""}, + + "web_article": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_article_authors": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_article_tags": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_articlefavourite": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_articlelogentry": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_articlesearchindex": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_articleversion": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_file": {owned, `article_id IN (SELECT id FROM art)`, ""}, + "web_vote": {owned, `article_id IN (SELECT id FROM art)`, ""}, + + "web_category": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_category_permissions_override": {owned, `category_id IN (SELECT id FROM cat)`, ""}, + "web_tag": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_tagscategory": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_theme": {owned, `site_id = (SELECT id FROM s)`, ""}, + + "web_role": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_rolecategory": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_role_permissions": {owned, `role_id IN (SELECT id FROM rol)`, ""}, + "web_role_restrictions": {owned, `role_id IN (SELECT id FROM rol)`, ""}, + "web_user_roles": {owned, `role_id IN (SELECT id FROM rol)`, ""}, + "web_rolepermissionsoverride": {owned, `id IN (SELECT id FROM ovr)`, ""}, + "web_rolepermissionsoverride_permissions": {owned, `rolepermissionsoverride_id IN (SELECT id FROM ovr)`, ""}, + "web_rolepermissionsoverride_restrictions": {owned, + `rolepermissionsoverride_id IN (SELECT id FROM ovr)`, ""}, + + "web_forumsection": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_forumcategory": {owned, `section_id IN (SELECT id FROM sec)`, ""}, + "web_forumthread": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_forumpost": {owned, `thread_id IN (SELECT id FROM thr)`, ""}, + "web_forumpostversion": {owned, `post_id IN (SELECT id FROM pst)`, ""}, + "web_forumpostlike": {owned, `post_id IN (SELECT id FROM pst)`, ""}, + + "web_invitelink": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_userreport": {owned, `site_id = (SELECT id FROM s)`, ""}, + "web_userticket": {owned, `site_id = (SELECT id FROM s)`, ""}, + "pwikit_admin_log": {owned, `site_id = (SELECT id FROM s)`, ""}, + + "pwikit_member_sanction": {owned, `site_id = (SELECT id FROM s)`, ""}, + + // A subscription can hang off an article or off a forum thread, and the + // thread need not belong to an article. + "web_usernotificationsubscription": {owned, + `article_id IN (SELECT id FROM art) OR forum_thread_id IN (SELECT id FROM thr)`, ""}, + + // A link whose other end stays behind is still worth keeping, since + // forgetting which site that was is the same thing as the link being red. + "web_externallink": {owned, `from_site_id = (SELECT id FROM s)`, ""}, + + "web_user": {people, "", ""}, + "dynamic_preferences_users_userpreferencemodel": {people, "", ""}, + + // The baseline seeds these and every instance holds the same rows, but the + // roles point at them, so they have to travel. + "auth_permission": {shared, "", ""}, + "django_content_type": {shared, "", ""}, + "django_migrations": {shared, "", ""}, + + "web_directmessage": {left, "", "private mail belongs to the two people, not to a site"}, + "web_directmessageblock": {left, "", "a block follows the person across every site"}, + "web_usernotification": {left, "", "a notice can point at a page on a site that is staying"}, + "web_usernotificationmapping": {left, "", "it hangs off a notice that is staying"}, + "pwikit_user_address": {left, "", "sign-in addresses are what the operator watches, not site content"}, + "web_usedtoken": {left, "", "a spent token is worth nothing anywhere"}, + "web_actionlogentry": {left, "", "nothing writes it any more"}, + "django_session": {left, "", "a session belongs to the server it was opened on"}, + "django_admin_log": {left, "", "nothing writes it any more"}, + "auth_group": {left, "", "roles took over from groups"}, + "auth_group_permissions": {left, "", "roles took over from groups"}, + "web_user_groups": {left, "", "roles took over from groups"}, + "web_user_user_permissions": {left, "", "roles took over from per-account rights"}, + "dynamic_preferences_globalpreferencemodel": {left, "", "it belongs to the instance"}, + "pwikit_update": {left, "", "it tracks the release the instance runs, not a site"}, +} + +// An empty return means the table does not travel. +func SiteExportQuery(ctx context.Context, conn *pgx.Conn, table string, keepPasswords bool) (string, error) { + r, ok := rules[table] + if !ok { + return "", fmt.Errorf("no rule says whether %s belongs to a site; add one to internal/db/backup_scope.go", table) + } + switch r.how { + case left: + return "", nil + case shared: + columns, err := columnList(ctx, conn, table, nil) + if err != nil { + return "", err + } + return `SELECT ` + columns + ` FROM ` + QuoteName(table) + ` t`, nil + case people: + return peopleFor(ctx, conn, table, keepPasswords) + default: + columns, err := columnList(ctx, conn, table, swaps[table]) + if err != nil { + return "", err + } + return scopeCTE + `SELECT ` + columns + ` FROM ` + QuoteName(table) + ` t WHERE ` + r.where, nil + } +} + +func peopleFor(ctx context.Context, conn *pgx.Conn, table string, keepPasswords bool) (string, error) { + if table != "web_user" { + columns, err := columnList(ctx, conn, table, nil) + if err != nil { + return "", err + } + return `WITH who AS (` + peopleQuery + `) SELECT ` + columns + ` FROM ` + QuoteName(table) + + ` t WHERE t.instance_id IN (SELECT id FROM who)`, nil + } + // Whoever runs the site now decides who administers it, and a key issued on + // the old instance should not open the new one. + swap := map[string]string{"is_superuser": `false`, "api_key": `NULL`} + if !keepPasswords { + // The marker every comparison fails against. The account arrives, but + // nobody signs in as it until they reset. + swap["password"] = `'!'` + } + columns, err := columnList(ctx, conn, table, swap) + if err != nil { + return "", err + } + return `WITH who AS (` + peopleQuery + `) SELECT ` + columns + + ` FROM web_user t WHERE t.id IN (SELECT id FROM who)`, nil +} + +// A generated column is left out, because a plain COPY of the table leaves it +// out too and a restore has to see the same shape either way. +func columnList(ctx context.Context, conn *pgx.Conn, table string, swap map[string]string) (string, error) { + rows, err := conn.Query(ctx, ` +SELECT a.attname +FROM pg_attribute a +JOIN pg_class c ON c.oid = a.attrelid +JOIN pg_namespace n ON n.oid = c.relnamespace +WHERE n.nspname = 'public' AND c.relname = $1 AND a.attnum > 0 AND NOT a.attisdropped + AND a.attgenerated = '' +ORDER BY a.attnum`, table) + if err != nil { + return "", fmt.Errorf("read the columns of %s: %w", table, err) + } + defer rows.Close() + + var parts []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return "", err + } + if with, ok := swap[name]; ok { + parts = append(parts, with+" AS "+QuoteName(name)) + continue + } + parts = append(parts, "t."+QuoteName(name)) + } + if err := rows.Err(); err != nil { + return "", err + } + return strings.Join(parts, ", "), nil +} + +// SiteScopeRuled lets a test hold the schema and this file to each other. +func SiteScopeRuled() []string { + out := make([]string, 0, len(rules)) + for name := range rules { + out = append(out, name) + } + sort.Strings(out) + return out +} diff --git a/internal/db/category_admin.go b/internal/db/category_admin.go new file mode 100644 index 00000000..6a5a6868 --- /dev/null +++ b/internal/db/category_admin.go @@ -0,0 +1,268 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type CategoryRow struct { + ID int64 + Name string + IsIndexed bool + Articles int + + Settings SiteSettings + Overrides []CategoryOverride +} + +type CategoryOverride struct { + RoleID int64 + RoleName string + Allow []string + Deny []string +} + +var qAdminCategories = register("AdminCategories", ` +SELECT c.id, c.name, c.is_indexed, + (SELECT count(*) FROM web_article a WHERE a.category = c.name AND a.site_id = c.site_id) +FROM web_category c WHERE c.site_id = $1 ORDER BY c.name, c.id`) + +func (d *DB) AdminCategories(ctx context.Context, siteID int64) ([]CategoryRow, error) { + rows, err := d.pool.Query(ctx, qAdminCategories, siteID) + if err != nil { + return nil, fmt.Errorf("list categories: %w", err) + } + defer rows.Close() + + var out []CategoryRow + for rows.Next() { + var c CategoryRow + if err := rows.Scan(&c.ID, &c.Name, &c.IsIndexed, &c.Articles); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +var qAdminCategory = register("AdminCategory", ` +SELECT id, name, is_indexed FROM web_category WHERE id = $1 AND site_id = $2`) + +var qCategorySettings = register("CategorySettings", ` +SELECT rating_mode, can_user_create_tags FROM web_settings WHERE category_id = $1`) + +var qAdminCategoryOverrides = register("AdminCategoryOverrides", ` +SELECT o.role_id, coalesce(nullif(r.name, ''), r.slug), p.codename, x.restricted +FROM web_category_permissions_override cpo +JOIN web_rolepermissionsoverride o ON o.id = cpo.rolepermissionsoverride_id +JOIN web_role r ON r.id = o.role_id +LEFT JOIN ( + SELECT rolepermissionsoverride_id AS override_id, permission_id, false AS restricted + FROM web_rolepermissionsoverride_permissions + UNION ALL + SELECT rolepermissionsoverride_id, permission_id, true + FROM web_rolepermissionsoverride_restrictions +) x ON x.override_id = o.id +LEFT JOIN auth_permission p ON p.id = x.permission_id +WHERE cpo.category_id = $1 +ORDER BY r.index, o.role_id`) + +func (d *DB) AdminCategory(ctx context.Context, siteID, id int64) (CategoryRow, error) { + var c CategoryRow + err := d.pool.QueryRow(ctx, qAdminCategory, id, siteID).Scan(&c.ID, &c.Name, &c.IsIndexed) + if errors.Is(err, pgx.ErrNoRows) { + return CategoryRow{}, ErrNotFound + } + if err != nil { + return CategoryRow{}, fmt.Errorf("read category %d: %w", id, err) + } + + settings, err := d.pool.Query(ctx, qCategorySettings, id) + if err != nil { + return CategoryRow{}, fmt.Errorf("read the settings of category %d: %w", id, err) + } + if settings.Next() { + if err := settings.Scan(&c.Settings.RatingMode, &c.Settings.CreateTags); err != nil { + settings.Close() + return CategoryRow{}, err + } + } + settings.Close() + if err := settings.Err(); err != nil { + return CategoryRow{}, err + } + + rows, err := d.pool.Query(ctx, qAdminCategoryOverrides, id) + if err != nil { + return CategoryRow{}, fmt.Errorf("read the overrides of category %d: %w", id, err) + } + defer rows.Close() + + byRole := map[int64]*CategoryOverride{} + for rows.Next() { + var roleID int64 + var roleName string + var codename *string + var restricted *bool + if err := rows.Scan(&roleID, &roleName, &codename, &restricted); err != nil { + return CategoryRow{}, err + } + one, ok := byRole[roleID] + if !ok { + one = &CategoryOverride{RoleID: roleID, RoleName: roleName} + byRole[roleID] = one + c.Overrides = append(c.Overrides, CategoryOverride{}) + } + if codename == nil || restricted == nil { + continue + } + if *restricted { + one.Deny = append(one.Deny, *codename) + } else { + one.Allow = append(one.Allow, *codename) + } + } + if err := rows.Err(); err != nil { + return CategoryRow{}, err + } + c.Overrides = c.Overrides[:0] + for _, one := range byRole { + c.Overrides = append(c.Overrides, *one) + } + return c, nil +} + +var ( + qInsertCategory = register("InsertCategory", `INSERT INTO web_category (name, is_indexed, site_id) VALUES ($1,$2,$3) RETURNING id`) + qUpdateCategory = register("UpdateCategory", `UPDATE web_category SET name=$2, is_indexed=$3 WHERE id=$1 AND site_id=$4`) + qUpsertCategorySettings = register("UpsertCategorySettings", ` +INSERT INTO web_settings (category_id, site_id, rating_mode, can_user_create_tags) +VALUES ($1, NULL, $2, $3) +ON CONFLICT (category_id) DO UPDATE SET rating_mode = EXCLUDED.rating_mode, + can_user_create_tags = EXCLUDED.can_user_create_tags`) +) + +func (d *DB) SaveCategory(ctx context.Context, siteID int64, c CategoryRow) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin saving category %q: %w", c.Name, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if c.ID == 0 { + if err := tx.QueryRow(ctx, qInsertCategory, c.Name, c.IsIndexed, siteID).Scan(&c.ID); err != nil { + return fmt.Errorf("create category %q: %w", c.Name, err) + } + } else if _, err := tx.Exec(ctx, qUpdateCategory, c.ID, c.Name, c.IsIndexed, siteID); err != nil { + return fmt.Errorf("update category %d: %w", c.ID, err) + } + if _, err := tx.Exec(ctx, qUpsertCategorySettings, c.ID, c.Settings.RatingMode, c.Settings.CreateTags); err != nil { + return fmt.Errorf("save the settings of category %d: %w", c.ID, err) + } + return tx.Commit(ctx) +} + +var ( + qCategoryOverrideIDs = register("CategoryOverrideIDs", ` +SELECT rolepermissionsoverride_id FROM web_category_permissions_override WHERE category_id = $1`) + qDropOverrideGrants = register("DropOverrideGrants", `DELETE FROM web_rolepermissionsoverride_permissions WHERE rolepermissionsoverride_id = ANY($1)`) + qDropOverrideDenies = register("DropOverrideDenies", `DELETE FROM web_rolepermissionsoverride_restrictions WHERE rolepermissionsoverride_id = ANY($1)`) + qDropCategoryLinks = register("DropCategoryLinks", `DELETE FROM web_category_permissions_override WHERE category_id = $1`) + qDropOverrides = register("DropOverrides", `DELETE FROM web_rolepermissionsoverride WHERE id = ANY($1)`) + qInsertOverride = register("InsertOverride", `INSERT INTO web_rolepermissionsoverride (role_id) VALUES ($1) RETURNING id`) + qLinkCategoryOverride = register("LinkCategoryOverride", ` +INSERT INTO web_category_permissions_override (category_id, rolepermissionsoverride_id) VALUES ($1, $2)`) + qGrantOverride = register("GrantOverride", ` +INSERT INTO web_rolepermissionsoverride_permissions (rolepermissionsoverride_id, permission_id) +SELECT $1, p.id FROM auth_permission p JOIN django_content_type c ON c.id = p.content_type_id +WHERE c.app_label = 'web' AND c.model = 'roles' AND p.codename = ANY($2)`) + qDenyOverride = register("DenyOverride", ` +INSERT INTO web_rolepermissionsoverride_restrictions (rolepermissionsoverride_id, permission_id) +SELECT $1, p.id FROM auth_permission p JOIN django_content_type c ON c.id = p.content_type_id +WHERE c.app_label = 'web' AND c.model = 'roles' AND p.codename = ANY($2)`) +) + +func (d *DB) SaveCategoryOverrides(ctx context.Context, categoryID int64, list []CategoryOverride) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin saving the overrides of category %d: %w", categoryID, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + var existing []int64 + rows, err := tx.Query(ctx, qCategoryOverrideIDs, categoryID) + if err != nil { + return fmt.Errorf("read the overrides of category %d: %w", categoryID, err) + } + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + existing = append(existing, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + + if _, err := tx.Exec(ctx, qDropCategoryLinks, categoryID); err != nil { + return err + } + if len(existing) > 0 { + for _, statement := range []string{qDropOverrideGrants, qDropOverrideDenies, qDropOverrides} { + if _, err := tx.Exec(ctx, statement, existing); err != nil { + return fmt.Errorf("clear the overrides of category %d: %w", categoryID, err) + } + } + } + + for _, one := range list { + var id int64 + if err := tx.QueryRow(ctx, qInsertOverride, one.RoleID).Scan(&id); err != nil { + return fmt.Errorf("create an override for role %d: %w", one.RoleID, err) + } + if _, err := tx.Exec(ctx, qLinkCategoryOverride, categoryID, id); err != nil { + return err + } + if len(one.Allow) > 0 { + if _, err := tx.Exec(ctx, qGrantOverride, id, one.Allow); err != nil { + return err + } + } + if len(one.Deny) > 0 { + if _, err := tx.Exec(ctx, qDenyOverride, id, one.Deny); err != nil { + return err + } + } + } + return tx.Commit(ctx) +} + +var ( + qDropCategorySettings = register("DropCategorySettings", `DELETE FROM web_settings WHERE category_id = $1`) + qDeleteCategory = register("DeleteCategory", `DELETE FROM web_category WHERE id = $1 AND site_id = $2`) +) + +func (d *DB) DeleteCategory(ctx context.Context, siteID, id int64) error { + if err := d.SaveCategoryOverrides(ctx, id, nil); err != nil { + return err + } + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin deleting category %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if _, err := tx.Exec(ctx, qDropCategorySettings, id); err != nil { + return fmt.Errorf("drop the settings of category %d: %w", id, err) + } + if _, err := tx.Exec(ctx, qDeleteCategory, id, siteID); err != nil { + return fmt.Errorf("delete category %d: %w", id, err) + } + return tx.Commit(ctx) +} diff --git a/internal/db/chrome.go b/internal/db/chrome.go new file mode 100644 index 00000000..90f5e4e3 --- /dev/null +++ b/internal/db/chrome.go @@ -0,0 +1,220 @@ +package db + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + "github.com/jackc/pgx/v5" +) + +var qBreadcrumbs = register("Breadcrumbs", ` +WITH RECURSIVE chain AS ( + SELECT id, parent_id, ARRAY[id] AS seen, 0 AS depth + FROM web_article + WHERE id = $1 + UNION ALL + SELECT p.id, p.parent_id, chain.seen || p.id, chain.depth + 1 + FROM web_article p + JOIN chain ON p.id = chain.parent_id + WHERE NOT p.id = ANY(chain.seen) +) +SELECT `+prefixedArticleColumns+` +FROM chain +JOIN web_article a ON a.id = chain.id +ORDER BY chain.depth DESC`) + +// Breadcrumbs walks up the parent chain and returns the root first. The seen +// array is what keeps a page that is its own ancestor from looping forever. +func (d *DB) Breadcrumbs(ctx context.Context, articleID int64) ([]Article, error) { + rows, err := d.pool.Query(ctx, qBreadcrumbs, articleID) + if err != nil { + return nil, fmt.Errorf("query breadcrumbs of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []Article + for rows.Next() { + var a Article + if err := rows.Scan(&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, + &a.Locked, &a.CreatedAt, &a.UpdatedAt, &a.MediaName); err != nil { + return nil, fmt.Errorf("scan breadcrumb of article %d: %w", articleID, err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read breadcrumbs of article %d: %w", articleID, err) + } + return out, nil +} + +type Tag struct { + Name string + FullName string +} + +type TagCategory struct { + ID int64 + Name string + Priority *int + Tags []Tag +} + +var qArticleTagCategories = register("ArticleTagCategories", ` +SELECT c.id, c.name, c.priority, t.name, + CASE WHEN c.slug = '_default' THEN t.name ELSE c.slug || ':' || t.name END +FROM web_article_tags link +JOIN web_tag t ON t.id = link.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE link.article_id = $1 AND t.name NOT LIKE '\_%' +ORDER BY c.id, link.id`) + +// A category with no priority sorts under the page's tag count, which is the +// number it is compared with. +func (d *DB) ArticleTagCategories(ctx context.Context, articleID int64) ([]TagCategory, error) { + rows, err := d.pool.Query(ctx, qArticleTagCategories, articleID) + if err != nil { + return nil, fmt.Errorf("query tag categories of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []TagCategory + byID := make(map[int64]int) + total := 0 + for rows.Next() { + var cat TagCategory + var tag Tag + if err := rows.Scan(&cat.ID, &cat.Name, &cat.Priority, &tag.Name, &tag.FullName); err != nil { + return nil, fmt.Errorf("scan tag category of article %d: %w", articleID, err) + } + at, ok := byID[cat.ID] + if !ok { + at = len(out) + byID[cat.ID] = at + out = append(out, cat) + } + out[at].Tags = append(out[at].Tags, tag) + total++ + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read tag categories of article %d: %w", articleID, err) + } + + priority := func(c TagCategory) int { + if c.Priority != nil { + return *c.Priority + } + return total + } + sort.SliceStable(out, func(i, j int) bool { return priority(out[i]) < priority(out[j]) }) + return out, nil +} + +var qLatestRevNumber = register("LatestRevNumber", ` +SELECT COALESCE(MAX(rev_number), 0) +FROM web_articlelogentry +WHERE article_id = $1`) + +func (d *DB) LatestRevNumber(ctx context.Context, articleID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qLatestRevNumber, articleID).Scan(&n); err != nil { + return 0, fmt.Errorf("query latest revision number of article %d: %w", articleID, err) + } + return n, nil +} + +var qCategoryExists = register("CategoryExists", ` +SELECT EXISTS(SELECT 1 FROM web_category WHERE site_id = $1 AND name = $2)`) + +// The page skips the permission check entirely when neither it nor its category +// has a row, which is how a 404 wins over a 403 there. +func (d *DB) CategoryExists(ctx context.Context, siteID int64, name string) (bool, error) { + var exists bool + if err := d.pool.QueryRow(ctx, qCategoryExists, siteID, name).Scan(&exists); err != nil { + return false, fmt.Errorf("check category %q: %w", name, err) + } + return exists, nil +} + +var qCategoryIndexed = register("CategoryIndexed", ` +SELECT is_indexed +FROM web_category +WHERE site_id = $1 AND name = $2`) + +func (d *DB) CategoryIndexed(ctx context.Context, siteID int64, name string) (bool, error) { + var indexed bool + err := d.pool.QueryRow(ctx, qCategoryIndexed, siteID, name).Scan(&indexed) + if errors.Is(err, pgx.ErrNoRows) { + return true, nil + } + if err != nil { + return false, fmt.Errorf("query category %q: %w", name, err) + } + return indexed, nil +} + +const ( + ThemeInline = "inline" + ThemeExternal = "external" +) + +type Theme struct { + Slug string + Mode string + ExternalURL string + UpdatedAt time.Time +} + +var qThemeByID = register("ThemeByID", ` +SELECT slug, mode, external_url, updated_at +FROM web_theme +WHERE id = $1`) + +func (d *DB) ThemeByID(ctx context.Context, id int64) (*Theme, error) { + var t Theme + err := d.pool.QueryRow(ctx, qThemeByID, id).Scan(&t.Slug, &t.Mode, &t.ExternalURL, &t.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup theme %d: %w", id, err) + } + return &t, nil +} + +var qArticleTagNames = register("ArticleTagNames", ` +SELECT CASE WHEN c.slug = '_default' THEN t.name ELSE c.slug || ':' || t.name END, + CASE WHEN c.slug = '_default' THEN '' ELSE t.name END +FROM web_article_tags link +JOIN web_tag t ON t.id = link.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE link.article_id = $1 +ORDER BY link.id`) + +// ArticleTagNames is the list ftml is told about, where a tag outside the +// default category appears twice, prefixed and bare. Hidden tags stay in. +func (d *DB) ArticleTagNames(ctx context.Context, articleID int64) ([]string, error) { + rows, err := d.pool.Query(ctx, qArticleTagNames, articleID) + if err != nil { + return nil, fmt.Errorf("query tag names of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var full, bare string + if err := rows.Scan(&full, &bare); err != nil { + return nil, fmt.Errorf("scan tag name of article %d: %w", articleID, err) + } + out = append(out, full) + if bare != "" { + out = append(out, bare) + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read tag names of article %d: %w", articleID, err) + } + return out, nil +} diff --git a/internal/db/chrome_test.go b/internal/db/chrome_test.go new file mode 100644 index 00000000..6949e912 --- /dev/null +++ b/internal/db/chrome_test.go @@ -0,0 +1,207 @@ +package db + +import ( + "context" + "testing" +) + +func articleID(t *testing.T, d *DB, ref string) int64 { + t.Helper() + a, err := d.ArticleByName(context.Background(), seedSiteID(t, d), ref) + if err != nil { + t.Fatalf("ArticleByName(%q) err = %v, want nil", ref, err) + } + return a.ID +} + +func TestBreadcrumbsReturnsRootFirst(t *testing.T) { + d := newTestDB(t) + + got, err := d.Breadcrumbs(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("Breadcrumbs() err = %v, want nil", err) + } + want := []string{"probe:parent", "probe:full"} + if len(got) != len(want) { + t.Fatalf("len(Breadcrumbs(probe:full)) = %d, want %d", len(got), len(want)) + } + for i, name := range want { + if got[i].FullName() != name { + t.Errorf("Breadcrumbs(probe:full)[%d] = %q, want %q", i, got[i].FullName(), name) + } + } +} + +func TestBreadcrumbsOfPageWithoutParent(t *testing.T) { + d := newTestDB(t) + + got, err := d.Breadcrumbs(context.Background(), articleID(t, d, "probe:parent")) + if err != nil { + t.Fatalf("Breadcrumbs() err = %v, want nil", err) + } + if len(got) != 1 { + t.Fatalf("len(Breadcrumbs(probe:parent)) = %d, want 1", len(got)) + } + if got[0].FullName() != "probe:parent" { + t.Errorf("Breadcrumbs(probe:parent)[0] = %q, want %q", got[0].FullName(), "probe:parent") + } +} + +func TestArticleTagCategoriesOrdersPriorityBeforeUnset(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTagCategories(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("ArticleTagCategories() err = %v, want nil", err) + } + if len(got) != 2 { + t.Fatalf("len(ArticleTagCategories(probe:full)) = %d, want 2", len(got)) + } + if got[0].Name != "lang" { + t.Errorf("ArticleTagCategories(probe:full)[0].Name = %q, want %q", got[0].Name, "lang") + } + if got[0].Priority == nil || *got[0].Priority != 1 { + t.Errorf("ArticleTagCategories(probe:full)[0].Priority = %v, want 1", got[0].Priority) + } + if got[1].Priority != nil { + t.Errorf("ArticleTagCategories(probe:full)[1].Priority = %v, want nil", got[1].Priority) + } +} + +func TestArticleTagCategoriesKeepsTagOrder(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTagCategories(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("ArticleTagCategories() err = %v, want nil", err) + } + if len(got) != 2 { + t.Fatalf("len(ArticleTagCategories(probe:full)) = %d, want 2", len(got)) + } + want := []string{"lang:en", "zeta", "alpha"} + var full []string + for _, category := range got { + for _, tag := range category.Tags { + full = append(full, tag.FullName) + } + } + if len(full) != len(want) { + t.Fatalf("len(tags of probe:full) = %d, want %d", len(full), len(want)) + } + for i := range want { + if full[i] != want[i] { + t.Errorf("tags of probe:full [%d] = %q, want %q", i, full[i], want[i]) + } + } +} + +func TestArticleTagCategoriesOfUntaggedPage(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTagCategories(context.Background(), articleID(t, d, "probe:bare")) + if err != nil { + t.Fatalf("ArticleTagCategories() err = %v, want nil", err) + } + if len(got) != 0 { + t.Errorf("len(ArticleTagCategories(probe:bare)) = %d, want 0", len(got)) + } +} + +func TestLatestRevNumber(t *testing.T) { + d := newTestDB(t) + + got, err := d.LatestRevNumber(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("LatestRevNumber() err = %v, want nil", err) + } + if got != 0 { + t.Errorf("LatestRevNumber(probe:full) = %d, want 0", got) + } +} + +func TestCategoryIndexedOfUnknownCategory(t *testing.T) { + d := newTestDB(t) + + got, err := d.CategoryIndexed(context.Background(), seedSiteID(t, d), "no-such-category") + if err != nil { + t.Fatalf("CategoryIndexed() err = %v, want nil", err) + } + if !got { + t.Errorf("CategoryIndexed(no-such-category) = false, want true") + } +} + +func TestUnreadNotificationsCountsOnlyUnviewed(t *testing.T) { + d := newTestDB(t) + + got, err := d.UnreadNotifications(context.Background(), userID(t, d, "probe-author")) + if err != nil { + t.Fatalf("UnreadNotifications() err = %v, want nil", err) + } + if got != 1 { + t.Errorf("UnreadNotifications(probe-author) = %d, want 1", got) + } +} + +func userID(t *testing.T, d *DB, name string) int64 { + t.Helper() + u, err := d.UserByName(context.Background(), name) + if err != nil { + t.Fatalf("UserByName(%q) err = %v, want nil", name, err) + } + return u.ID +} + +func TestArticleTagNamesRepeatsPrefixedTags(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTagNames(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("ArticleTagNames() err = %v, want nil", err) + } + want := []string{"zeta", "alpha", "lang:en", "en"} + if len(got) != len(want) { + t.Fatalf("len(ArticleTagNames(probe:full)) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("ArticleTagNames(probe:full)[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestArticleTagNamesOfUntaggedPage(t *testing.T) { + d := newTestDB(t) + + got, err := d.ArticleTagNames(context.Background(), articleID(t, d, "probe:bare")) + if err != nil { + t.Fatalf("ArticleTagNames() err = %v, want nil", err) + } + if len(got) != 0 { + t.Errorf("len(ArticleTagNames(probe:bare)) = %d, want 0", len(got)) + } +} + +func TestCategoryExists(t *testing.T) { + d := newTestDB(t) + + got, err := d.CategoryExists(context.Background(), seedSiteID(t, d), "probestars") + if err != nil { + t.Fatalf("CategoryExists() err = %v, want nil", err) + } + if !got { + t.Errorf("CategoryExists(probestars) = false, want true") + } +} + +func TestCategoryExistsOfUnknownCategory(t *testing.T) { + d := newTestDB(t) + + got, err := d.CategoryExists(context.Background(), seedSiteID(t, d), "no-such-category") + if err != nil { + t.Fatalf("CategoryExists() err = %v, want nil", err) + } + if got { + t.Errorf("CategoryExists(no-such-category) = true, want false") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 00000000..617a89d9 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,90 @@ +// Package db is the only place in pwikit that issues SQL. +package db + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("db: not found") + +// EnvDSN points database-backed tests at a live Postgres. It lives here rather +// than in a _test.go file because other packages' tests gate on the same name. +const EnvDSN = "PWIKIT_TEST_DSN" + +// A test that writes needs a database of its own. Rows it leaves standing for a +// moment are visible to every other package's tests while they run. +const EnvWriteDSN = "PWIKIT_TEST_WRITE_DSN" + +type DB struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, dsn string) (*DB, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + // pgx returns timestamps in the server machine's zone, which leaks into anything formatted. + cfg.AfterConnect = func(_ context.Context, conn *pgx.Conn) error { + conn.TypeMap().RegisterType(&pgtype.Type{ + Name: "timestamptz", + OID: pgtype.TimestamptzOID, + Codec: &pgtype.TimestamptzCodec{ScanLocation: time.UTC}, + }) + return nil + } + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping database: %w", err) + } + return &DB{pool: pool}, nil +} + +func (d *DB) Close() { + d.pool.Close() +} + +type query struct { + name string + sql string +} + +// Every statement goes through register so the schema-drift test can reach all +// of them; a query held in a plain const would silently escape that check. +var queries []query + +func register(name, sql string) string { + queries = append(queries, query{name: name, sql: sql}) + return sql +} + +// Each statement of an ordered sequence is registered on its own, so the +// schema-drift test still sees every one of them. +func registerAll(name string, sqls []string) []string { + for i, sql := range sqls { + register(fmt.Sprintf("%s.%d", name, i), sql) + } + return sqls +} + +// prefixed qualifies a column list with a table alias so joins can reuse the +// one list the scan order is written against. +func prefixed(alias, columns string) string { + parts := strings.Split(columns, ", ") + for i, c := range parts { + parts[i] = alias + "." + c + } + return strings.Join(parts, ", ") +} diff --git a/internal/db/email_write.go b/internal/db/email_write.go new file mode 100644 index 00000000..075de71c --- /dev/null +++ b/internal/db/email_write.go @@ -0,0 +1,131 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type AccountEmail struct { + Email string + Pending string + Previous string + VerifiedAt *time.Time + ChangedAt *time.Time +} + +var qAccountEmail = register("AccountEmail", ` +SELECT email, pending_email, previous_email, email_verified_at, email_changed_at +FROM web_user +WHERE id = $1`) + +func (d *DB) AccountEmail(ctx context.Context, id int64) (AccountEmail, error) { + var a AccountEmail + err := d.pool.QueryRow(ctx, qAccountEmail, id). + Scan(&a.Email, &a.Pending, &a.Previous, &a.VerifiedAt, &a.ChangedAt) + if errors.Is(err, pgx.ErrNoRows) { + return AccountEmail{}, ErrNotFound + } + if err != nil { + return AccountEmail{}, fmt.Errorf("read account email of %d: %w", id, err) + } + return a, nil +} + +var qVerifiedEmailTaken = register("VerifiedEmailTaken", ` +SELECT EXISTS ( + SELECT 1 FROM web_user + WHERE lower(email) = lower($1) AND email_verified_at IS NOT NULL AND id <> $2)`) + +func (d *DB) VerifiedEmailTaken(ctx context.Context, email string, except int64) (bool, error) { + var taken bool + if err := d.pool.QueryRow(ctx, qVerifiedEmailTaken, email, except).Scan(&taken); err != nil { + return false, fmt.Errorf("check email %q: %w", email, err) + } + return taken, nil +} + +var qMarkEmailVerified = register("MarkEmailVerified", ` +UPDATE web_user +SET email_verified_at = $2, pending_email = '' +WHERE id = $1 AND lower(email) = lower($3)`) + +func (d *DB) MarkEmailVerified(ctx context.Context, id int64, email string, at time.Time) (bool, error) { + tag, err := d.pool.Exec(ctx, qMarkEmailVerified, id, at, email) + if err != nil { + return false, fmt.Errorf("verify email of %d: %w", id, err) + } + return tag.RowsAffected() > 0, nil +} + +var qSetPendingEmail = register("SetPendingEmail", ` +UPDATE web_user SET pending_email = $2 WHERE id = $1`) + +func (d *DB) SetPendingEmail(ctx context.Context, id int64, email string) error { + if _, err := d.pool.Exec(ctx, qSetPendingEmail, id, email); err != nil { + return fmt.Errorf("store pending email of %d: %w", id, err) + } + return nil +} + +var qApplyPendingEmail = register("ApplyPendingEmail", ` +UPDATE web_user +SET previous_email = email, email = pending_email, pending_email = '', + email_verified_at = $2, email_changed_at = $2 +WHERE id = $1 AND lower(pending_email) = lower($3)`) + +func (d *DB) ApplyPendingEmail(ctx context.Context, id int64, pending string, at time.Time) (bool, error) { + tag, err := d.pool.Exec(ctx, qApplyPendingEmail, id, at, pending) + if err != nil { + return false, fmt.Errorf("apply pending email of %d: %w", id, err) + } + return tag.RowsAffected() > 0, nil +} + +var qRevertEmail = register("RevertEmail", ` +UPDATE web_user +SET email = previous_email, previous_email = '', pending_email = '', + email_verified_at = $2, email_changed_at = $2 +WHERE id = $1 AND previous_email <> '' AND lower(previous_email) = lower($3)`) + +func (d *DB) RevertEmail(ctx context.Context, id int64, previous string, at time.Time) (bool, error) { + tag, err := d.pool.Exec(ctx, qRevertEmail, id, at, previous) + if err != nil { + return false, fmt.Errorf("revert email of %d: %w", id, err) + } + return tag.RowsAffected() > 0, nil +} + +var qSetUsername = register("SetUsername", ` +UPDATE web_user +SET username = $2, display_name = $3, username_changed_at = $4 +WHERE id = $1`) + +func (d *DB) SetUsername(ctx context.Context, id int64, username, displayName string, at time.Time) error { + var display *string + if displayName != "" { + display = &displayName + } + if _, err := d.pool.Exec(ctx, qSetUsername, id, username, display, at); err != nil { + return fmt.Errorf("rename user %d: %w", id, err) + } + return nil +} + +var qUsernameChangedAt = register("UsernameChangedAt", ` +SELECT username_changed_at FROM web_user WHERE id = $1`) + +func (d *DB) UsernameChangedAt(ctx context.Context, id int64) (*time.Time, error) { + var at *time.Time + err := d.pool.QueryRow(ctx, qUsernameChangedAt, id).Scan(&at) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("read rename time of %d: %w", id, err) + } + return at, nil +} diff --git a/internal/db/favourite_write.go b/internal/db/favourite_write.go new file mode 100644 index 00000000..8b8604f4 --- /dev/null +++ b/internal/db/favourite_write.go @@ -0,0 +1,100 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +var qArticleFavouriteCount = register("ArticleFavouriteCount", ` +SELECT count(*) FROM web_articlefavourite WHERE article_id = $1`) + +func (d *DB) ArticleFavouriteCount(ctx context.Context, articleID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qArticleFavouriteCount, articleID).Scan(&n); err != nil { + return 0, fmt.Errorf("count favourites of article %d: %w", articleID, err) + } + return n, nil +} + +var qHasFavourited = register("HasFavourited", ` +SELECT EXISTS (SELECT 1 FROM web_articlefavourite WHERE article_id = $1 AND user_id = $2)`) + +func (d *DB) HasFavourited(ctx context.Context, articleID, userID int64) (bool, error) { + var yes bool + if err := d.pool.QueryRow(ctx, qHasFavourited, articleID, userID).Scan(&yes); err != nil { + return false, fmt.Errorf("read favourite of article %d: %w", articleID, err) + } + return yes, nil +} + +var qAddFavourite = register("AddFavourite", ` +INSERT INTO web_articlefavourite (article_id, user_id, created_at) +VALUES ($1, $2, $3) +ON CONFLICT DO NOTHING`) + +func (d *DB) AddFavourite(ctx context.Context, articleID, userID int64, at time.Time) error { + if _, err := d.pool.Exec(ctx, qAddFavourite, articleID, userID, at); err != nil { + return fmt.Errorf("favourite article %d: %w", articleID, err) + } + return nil +} + +var qRemoveFavourite = register("RemoveFavourite", ` +DELETE FROM web_articlefavourite WHERE article_id = $1 AND user_id = $2`) + +func (d *DB) RemoveFavourite(ctx context.Context, articleID, userID int64) error { + if _, err := d.pool.Exec(ctx, qRemoveFavourite, articleID, userID); err != nil { + return fmt.Errorf("unfavourite article %d: %w", articleID, err) + } + return nil +} + +type Favourite struct { + Article Article + AddedAt time.Time +} + +var qFavouritesOf = register("FavouritesOf", ` +SELECT `+prefixedArticleColumns+`, f.created_at +FROM web_articlefavourite f +JOIN web_article a ON a.id = f.article_id +WHERE f.user_id = $1 AND a.site_id = $4 +ORDER BY f.created_at DESC, f.id DESC +OFFSET $2 LIMIT $3`) + +// Only the owner ever reads this, so no permission filter runs here. Whoever +// calls it has already established that the rows belong to the reader. +func (d *DB) FavouritesOf(ctx context.Context, siteID, userID int64, offset, limit int) ([]Favourite, error) { + rows, err := d.pool.Query(ctx, qFavouritesOf, userID, offset, limit, siteID) + if err != nil { + return nil, fmt.Errorf("list favourites of user %d: %w", userID, err) + } + defer rows.Close() + + var out []Favourite + for rows.Next() { + var one Favourite + a := &one.Article + if err := rows.Scan(&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, + &a.Locked, &a.CreatedAt, &a.UpdatedAt, &a.MediaName, &one.AddedAt); err != nil { + return nil, fmt.Errorf("scan favourite: %w", err) + } + out = append(out, one) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list favourites of user %d: %w", userID, err) + } + return out, nil +} + +var qFavouriteCountOf = register("FavouriteCountOf", ` +SELECT count(*) FROM web_articlefavourite WHERE user_id = $1`) + +func (d *DB) FavouriteCountOf(ctx context.Context, userID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qFavouriteCountOf, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("count favourites of user %d: %w", userID, err) + } + return n, nil +} diff --git a/internal/db/favourite_write_test.go b/internal/db/favourite_write_test.go new file mode 100644 index 00000000..aa32d1ad --- /dev/null +++ b/internal/db/favourite_write_test.go @@ -0,0 +1,120 @@ +package db + +import ( + "context" + "testing" + "time" +) + +func TestAddFavouriteCountsOnce(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + user := scratchUser(t, d, "probe-fav-a") + + for i := 0; i < 2; i++ { + if err := d.AddFavourite(ctx, article, user, time.Now().UTC()); err != nil { + t.Fatalf("AddFavourite() err = %v, want nil", err) + } + } + got, err := d.ArticleFavouriteCount(ctx, article) + if err != nil { + t.Fatalf("ArticleFavouriteCount() err = %v, want nil", err) + } + if got != 1 { + t.Errorf("ArticleFavouriteCount() = %d, want 1", got) + } +} + +func TestRemoveFavouriteTakesItBack(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + user := scratchUser(t, d, "probe-fav-b") + + if err := d.AddFavourite(ctx, article, user, time.Now().UTC()); err != nil { + t.Fatalf("AddFavourite() err = %v, want nil", err) + } + if err := d.RemoveFavourite(ctx, article, user); err != nil { + t.Fatalf("RemoveFavourite() err = %v, want nil", err) + } + got, err := d.HasFavourited(ctx, article, user) + if err != nil { + t.Fatalf("HasFavourited() err = %v, want nil", err) + } + if got { + t.Errorf("HasFavourited() = true, want false") + } +} + +func TestHasFavouritedIsPerUser(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + mine := scratchUser(t, d, "probe-fav-c") + other := scratchUser(t, d, "probe-fav-d") + + if err := d.AddFavourite(ctx, article, mine, time.Now().UTC()); err != nil { + t.Fatalf("AddFavourite() err = %v, want nil", err) + } + yes, err := d.HasFavourited(ctx, article, mine) + if err != nil { + t.Fatalf("HasFavourited() err = %v, want nil", err) + } + if !yes { + t.Errorf("HasFavourited(mine) = false, want true") + } + no, err := d.HasFavourited(ctx, article, other) + if err != nil { + t.Fatalf("HasFavourited() err = %v, want nil", err) + } + if no { + t.Errorf("HasFavourited(other) = true, want false") + } +} + +func TestFavouritesOfComeBackNewestFirstInAWindow(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + user := scratchUser(t, d, "probe-fav-e") + at := time.Now().UTC() + + ids := make([]int64, 0, 3) + for i := 0; i < 3; i++ { + article := scratchArticle(t, d) + ids = append(ids, article) + if err := d.AddFavourite(ctx, article, user, at.Add(time.Duration(i)*time.Minute)); err != nil { + t.Fatalf("AddFavourite() err = %v, want nil", err) + } + } + + total, err := d.FavouriteCountOf(ctx, user) + if err != nil { + t.Fatalf("FavouriteCountOf() err = %v, want nil", err) + } + if total != 3 { + t.Errorf("FavouriteCountOf() = %d, want 3", total) + } + + first, err := d.FavouritesOf(ctx, seedSiteID(t, d), user, 0, 2) + if err != nil { + t.Fatalf("FavouritesOf() err = %v, want nil", err) + } + if len(first) != 2 { + t.Fatalf("len(FavouritesOf(0, 2)) = %d, want 2", len(first)) + } + if first[0].Article.ID != ids[2] { + t.Errorf("FavouritesOf()[0].ID = %d, want %d", first[0].Article.ID, ids[2]) + } + + second, err := d.FavouritesOf(ctx, seedSiteID(t, d), user, 2, 2) + if err != nil { + t.Fatalf("FavouritesOf() err = %v, want nil", err) + } + if len(second) != 1 { + t.Fatalf("len(FavouritesOf(2, 2)) = %d, want 1", len(second)) + } + if second[0].Article.ID != ids[0] { + t.Errorf("FavouritesOf(2, 2)[0].ID = %d, want %d", second[0].Article.ID, ids[0]) + } +} diff --git a/internal/db/file.go b/internal/db/file.go new file mode 100644 index 00000000..f071400a --- /dev/null +++ b/internal/db/file.go @@ -0,0 +1,77 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type ArticleFile struct { + ArticleMediaName string + MediaName string + MimeType string + Size int64 +} + +// f.name is a plain text column, so this comparison is case-sensitive; only +// complete_full_name is citext. +var qArticleFile = register("ArticleFile", ` +SELECT a.media_name, f.media_name, f.mime_type, f.size +FROM web_file f +JOIN web_article a ON a.id = f.article_id +WHERE a.site_id = $1 AND a.complete_full_name = $2 AND f.name = $3 AND f.deleted_at IS NULL +ORDER BY f.id +LIMIT 1`) + +// ArticleFile resolves an attachment's on-disk names. ORDER BY id only makes +// an already-unique row deterministic. +func (d *DB) ArticleFile(ctx context.Context, siteID int64, articleRef, fileName string) (*ArticleFile, error) { + var f ArticleFile + err := d.pool.QueryRow(ctx, qArticleFile, siteID, dumbName(articleRef), fileName).Scan( + &f.ArticleMediaName, &f.MediaName, &f.MimeType, &f.Size) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup file %q in article %q: %w", fileName, articleRef, err) + } + return &f, nil +} + +type ArticleFileEntry struct { + ID int64 + Name string + MimeType string + Size int64 +} + +var qArticleFiles = register("ArticleFiles", ` +SELECT f.id, f.name, f.mime_type, f.size +FROM web_file f +WHERE f.article_id = $1 AND f.deleted_at IS NULL +ORDER BY f.name, f.id`) + +// The name is unique per article only among the rows still alive, so the id +// breaks the tie for a name that was deleted and uploaded again. +func (d *DB) ArticleFiles(ctx context.Context, articleID int64) ([]ArticleFileEntry, error) { + rows, err := d.pool.Query(ctx, qArticleFiles, articleID) + if err != nil { + return nil, fmt.Errorf("list files of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []ArticleFileEntry + for rows.Next() { + var f ArticleFileEntry + if err := rows.Scan(&f.ID, &f.Name, &f.MimeType, &f.Size); err != nil { + return nil, fmt.Errorf("scan file: %w", err) + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list files of article %d: %w", articleID, err) + } + return out, nil +} diff --git a/internal/db/file_write.go b/internal/db/file_write.go new file mode 100644 index 00000000..ec60fa50 --- /dev/null +++ b/internal/db/file_write.go @@ -0,0 +1,130 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type FileRecord struct { + ID int64 + Name string + MimeType string + Size int64 + CreatedAt time.Time + AuthorID *int64 +} + +// Newest last. Ordering by name instead would reshuffle the whole list every +// time one file is renamed. +var qArticleFileList = register("ArticleFileList", ` +SELECT id, name, mime_type, size, created_at, author_id +FROM web_file +WHERE article_id = $1 AND deleted_at IS NULL +ORDER BY id`) + +func (d *DB) ArticleFileList(ctx context.Context, articleID int64) ([]FileRecord, error) { + rows, err := d.pool.Query(ctx, qArticleFileList, articleID) + if err != nil { + return nil, fmt.Errorf("list files of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []FileRecord + for rows.Next() { + var f FileRecord + if err := rows.Scan(&f.ID, &f.Name, &f.MimeType, &f.Size, &f.CreatedAt, &f.AuthorID); err != nil { + return nil, fmt.Errorf("scan file: %w", err) + } + out = append(out, f) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list files of article %d: %w", articleID, err) + } + return out, nil +} + +// A deleted row still occupies the disk, so the two totals differ by exactly +// what a purge would free. +var qFileSpaceUsage = register("FileSpaceUsage", ` +SELECT COALESCE(SUM(size) FILTER (WHERE deleted_at IS NULL), 0), COALESCE(SUM(size), 0) +FROM web_file`) + +func (d *DB) FileSpaceUsage(ctx context.Context) (live, total int64, err error) { + if err := d.pool.QueryRow(ctx, qFileSpaceUsage).Scan(&live, &total); err != nil { + return 0, 0, fmt.Errorf("sum file sizes: %w", err) + } + return live, total, nil +} + +type FileRow struct { + ID int64 + ArticleID int64 + Name string + MediaName string + Deleted bool +} + +var qFileByID = register("FileByID", ` +SELECT id, article_id, name, media_name, deleted_at IS NOT NULL +FROM web_file +WHERE id = $1`) + +func (d *DB) FileByID(ctx context.Context, fileID int64) (*FileRow, error) { + var f FileRow + err := d.pool.QueryRow(ctx, qFileByID, fileID).Scan(&f.ID, &f.ArticleID, &f.Name, &f.MediaName, &f.Deleted) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup file %d: %w", fileID, err) + } + return &f, nil +} + +var qLiveFileNamed = register("LiveFileNamed", ` +SELECT id +FROM web_file +WHERE article_id = $1 AND name = $2 AND deleted_at IS NULL +ORDER BY id +LIMIT 1`) + +func (d *DB) LiveFileNamed(ctx context.Context, articleID int64, name string) (int64, error) { + var id int64 + err := d.pool.QueryRow(ctx, qLiveFileNamed, articleID, name).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrNotFound + } + if err != nil { + return 0, fmt.Errorf("lookup file %q in article %d: %w", name, articleID, err) + } + return id, nil +} + +type FileWrite struct { + ArticleID int64 + Name string + MediaName string + MimeType string + Size int64 + AuthorID *int64 + At time.Time +} + +var qInsertFile = register("InsertFile", ` +INSERT INTO web_file (article_id, name, media_name, mime_type, size, author_id, created_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id`) + +func (d *DB) AddArticleFile(ctx context.Context, w FileWrite) (int64, error) { + var id int64 + err := d.pool.QueryRow(ctx, qInsertFile, w.ArticleID, w.Name, w.MediaName, + w.MimeType, w.Size, w.AuthorID, w.At).Scan(&id) + if err != nil { + return 0, fmt.Errorf("write file %q of article %d: %w", w.Name, w.ArticleID, err) + } + return id, nil +} diff --git a/internal/db/file_write_test.go b/internal/db/file_write_test.go new file mode 100644 index 00000000..955b76d7 --- /dev/null +++ b/internal/db/file_write_test.go @@ -0,0 +1,215 @@ +package db + +import ( + "context" + "errors" + "testing" + "time" +) + +func scratchFile(t *testing.T, d *DB, articleID int64, name string, size int64) int64 { + t.Helper() + id, err := d.AddArticleFile(context.Background(), FileWrite{ + ArticleID: articleID, + Name: name, + MediaName: name + ".bin", + MimeType: "application/octet-stream", + Size: size, + At: time.Now().UTC(), + }) + if err != nil { + t.Fatalf("AddArticleFile(%q) err = %v, want nil", name, err) + } + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), `DELETE FROM web_file WHERE id = $1`, id); err != nil { + t.Errorf("clean up scratch file err = %v, want nil", err) + } + }) + return id +} + +func TestAddArticleFileIsFoundByName(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + id := scratchFile(t, d, article, "probe-upload.txt", 12) + + found, err := d.LiveFileNamed(ctx, article, "probe-upload.txt") + if err != nil { + t.Fatalf("LiveFileNamed() err = %v, want nil", err) + } + if found != id { + t.Errorf("LiveFileNamed() = %d, want %d", found, id) + } +} + +func TestLiveFileNamedMissesAnotherName(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + scratchFile(t, d, article, "probe-upload.txt", 12) + + if _, err := d.LiveFileNamed(ctx, article, "probe-other.txt"); !errors.Is(err, ErrNotFound) { + t.Errorf("LiveFileNamed() err = %v, want ErrNotFound", err) + } +} + +func TestLiveFileNamedMissesADeletedFile(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + id := scratchFile(t, d, article, "probe-upload.txt", 12) + + if _, _, err := d.SoftDeleteFile(ctx, id, time.Now().UTC(), nil); err != nil { + t.Fatalf("SoftDeleteFile() err = %v, want nil", err) + } + if _, err := d.LiveFileNamed(ctx, article, "probe-upload.txt"); !errors.Is(err, ErrNotFound) { + t.Errorf("LiveFileNamed() err = %v, want ErrNotFound", err) + } +} + +func TestArticleFileListLeavesOutADeletedFile(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + kept := scratchFile(t, d, article, "probe-kept.txt", 4) + gone := scratchFile(t, d, article, "probe-gone.txt", 4) + + if _, _, err := d.SoftDeleteFile(ctx, gone, time.Now().UTC(), nil); err != nil { + t.Fatalf("SoftDeleteFile() err = %v, want nil", err) + } + list, err := d.ArticleFileList(ctx, article) + if err != nil { + t.Fatalf("ArticleFileList() err = %v, want nil", err) + } + if len(list) != 1 { + t.Fatalf("len(ArticleFileList()) = %d, want 1", len(list)) + } + if list[0].ID != kept { + t.Errorf("ArticleFileList()[0].ID = %d, want %d", list[0].ID, kept) + } +} + +func TestArticleFileListOrdersByUpload(t *testing.T) { + d := writeTestDB(t) + article := scratchArticle(t, d) + first := scratchFile(t, d, article, "zzz-first.txt", 4) + second := scratchFile(t, d, article, "aaa-second.txt", 4) + + list, err := d.ArticleFileList(context.Background(), article) + if err != nil { + t.Fatalf("ArticleFileList() err = %v, want nil", err) + } + if len(list) != 2 { + t.Fatalf("len(ArticleFileList()) = %d, want 2", len(list)) + } + if list[0].ID != first { + t.Errorf("ArticleFileList()[0].ID = %d, want %d", list[0].ID, first) + } + if list[1].ID != second { + t.Errorf("ArticleFileList()[1].ID = %d, want %d", list[1].ID, second) + } +} + +func TestArticleFileListCarriesWhatWasWritten(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + at := time.Date(2026, 3, 4, 5, 6, 7, 891000000, time.UTC) + + id, err := d.AddArticleFile(ctx, FileWrite{ + ArticleID: article, + Name: "probe-detail.pdf", + MediaName: "8ab0e9f2.pdf", + MimeType: "application/pdf", + Size: 4096, + At: at, + }) + if err != nil { + t.Fatalf("AddArticleFile() err = %v, want nil", err) + } + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), `DELETE FROM web_file WHERE id = $1`, id); err != nil { + t.Errorf("clean up scratch file err = %v, want nil", err) + } + }) + + list, err := d.ArticleFileList(ctx, article) + if err != nil { + t.Fatalf("ArticleFileList() err = %v, want nil", err) + } + if len(list) != 1 { + t.Fatalf("len(ArticleFileList()) = %d, want 1", len(list)) + } + got := list[0] + if got.Name != "probe-detail.pdf" { + t.Errorf("ArticleFileList()[0].Name = %q, want %q", got.Name, "probe-detail.pdf") + } + if got.MimeType != "application/pdf" { + t.Errorf("ArticleFileList()[0].MimeType = %q, want %q", got.MimeType, "application/pdf") + } + if got.Size != 4096 { + t.Errorf("ArticleFileList()[0].Size = %d, want 4096", got.Size) + } + if !got.CreatedAt.Equal(at) { + t.Errorf("ArticleFileList()[0].CreatedAt = %v, want %v", got.CreatedAt.UTC(), at) + } + if got.AuthorID != nil { + t.Errorf("ArticleFileList()[0].AuthorID = %v, want nil", *got.AuthorID) + } +} + +func TestFileByIDReportsADeletedFile(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + id := scratchFile(t, d, article, "probe-gone.txt", 4) + + if _, _, err := d.SoftDeleteFile(ctx, id, time.Now().UTC(), nil); err != nil { + t.Fatalf("SoftDeleteFile() err = %v, want nil", err) + } + found, err := d.FileByID(ctx, id) + if err != nil { + t.Fatalf("FileByID() err = %v, want nil", err) + } + if !found.Deleted { + t.Errorf("FileByID().Deleted = false, want true") + } + if found.ArticleID != article { + t.Errorf("FileByID().ArticleID = %d, want %d", found.ArticleID, article) + } +} + +func TestFileByIDOfNothing(t *testing.T) { + d := writeTestDB(t) + if _, err := d.FileByID(context.Background(), -1); !errors.Is(err, ErrNotFound) { + t.Errorf("FileByID(-1) err = %v, want ErrNotFound", err) + } +} + +func TestFileSpaceUsageCountsADeletedFileOnlyInTheTotal(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + + beforeLive, beforeTotal, err := d.FileSpaceUsage(ctx) + if err != nil { + t.Fatalf("FileSpaceUsage() err = %v, want nil", err) + } + scratchFile(t, d, article, "probe-kept.txt", 100) + gone := scratchFile(t, d, article, "probe-gone.txt", 700) + if _, _, err := d.SoftDeleteFile(ctx, gone, time.Now().UTC(), nil); err != nil { + t.Fatalf("SoftDeleteFile() err = %v, want nil", err) + } + + live, total, err := d.FileSpaceUsage(ctx) + if err != nil { + t.Fatalf("FileSpaceUsage() err = %v, want nil", err) + } + if live-beforeLive != 100 { + t.Errorf("FileSpaceUsage() live grew by %d, want 100", live-beforeLive) + } + if total-beforeTotal != 800 { + t.Errorf("FileSpaceUsage() total grew by %d, want 800", total-beforeTotal) + } +} diff --git a/internal/db/forum.go b/internal/db/forum.go new file mode 100644 index 00000000..450db032 --- /dev/null +++ b/internal/db/forum.go @@ -0,0 +1,648 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type ForumSection struct { + ID int64 + Name string + Description string + IsHidden bool + IsHiddenForUsers bool +} + +type ForumCategory struct { + ID int64 + SectionID int64 + Name string + Description string + IsForComments bool +} + +// ForumLastPost carries ids rather than rows so the caller can reach for the +// article and the author through the lookups it already has. +type ForumLastPost struct { + ID int64 + CreatedAt time.Time + ThreadID int64 + ThreadName string + ThreadCategoryID *int64 + ThreadArticleID *int64 + AuthorID *int64 +} + +type ForumCounts struct { + Threads int + Posts int +} + +const forumSectionColumns = `id, name, description, is_hidden, is_hidden_for_users` + +var qForumSections = register("ForumSections", ` +SELECT `+forumSectionColumns+` +FROM web_forumsection +WHERE site_id = $1 +ORDER BY "order", id`) + +func (d *DB) ForumSections(ctx context.Context, siteID int64) ([]ForumSection, error) { + rows, err := d.pool.Query(ctx, qForumSections, siteID) + if err != nil { + return nil, fmt.Errorf("query forum sections: %w", err) + } + defer rows.Close() + + var out []ForumSection + for rows.Next() { + var s ForumSection + if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.IsHidden, &s.IsHiddenForUsers); err != nil { + return nil, fmt.Errorf("scan forum section: %w", err) + } + out = append(out, s) + } + return out, rows.Err() +} + +var qForumSection = register("ForumSection", ` +SELECT `+forumSectionColumns+` +FROM web_forumsection +WHERE id = $1`) + +func (d *DB) ForumSection(ctx context.Context, id int64) (*ForumSection, error) { + var s ForumSection + err := d.pool.QueryRow(ctx, qForumSection, id).Scan( + &s.ID, &s.Name, &s.Description, &s.IsHidden, &s.IsHiddenForUsers) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query forum section %d: %w", id, err) + } + return &s, nil +} + +var qForumCategories = register("ForumCategories", ` +SELECT c.id, c.section_id, c.name, c.description, c.is_for_comments +FROM web_forumcategory c +JOIN web_forumsection s ON s.id = c.section_id +WHERE s.site_id = $1 +ORDER BY c."order", c.id`) + +func (d *DB) ForumCategories(ctx context.Context, siteID int64) ([]ForumCategory, error) { + rows, err := d.pool.Query(ctx, qForumCategories, siteID) + if err != nil { + return nil, fmt.Errorf("query forum categories: %w", err) + } + defer rows.Close() + + var out []ForumCategory + for rows.Next() { + var c ForumCategory + if err := rows.Scan(&c.ID, &c.SectionID, &c.Name, &c.Description, &c.IsForComments); err != nil { + return nil, fmt.Errorf("scan forum category: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +var qForumCategoryCounts = register("ForumCategoryCounts", ` +SELECT (SELECT count(*) FROM web_forumthread WHERE category_id = $1), + (SELECT count(*) FROM web_forumpost p + JOIN web_forumthread t ON t.id = p.thread_id + WHERE t.category_id = $1)`) + +func (d *DB) ForumCategoryCounts(ctx context.Context, categoryID int64) (ForumCounts, error) { + var c ForumCounts + if err := d.pool.QueryRow(ctx, qForumCategoryCounts, categoryID).Scan(&c.Threads, &c.Posts); err != nil { + return ForumCounts{}, fmt.Errorf("count forum category %d: %w", categoryID, err) + } + return c, nil +} + +// A category marked for comments counts every article's thread, whichever +// category the reader is looking at. +var qForumCommentCounts = register("ForumCommentCounts", ` +SELECT (SELECT count(*) FROM web_forumthread WHERE site_id = $1 AND article_id IS NOT NULL), + (SELECT count(*) FROM web_forumpost p + JOIN web_forumthread t ON t.id = p.thread_id + WHERE t.site_id = $1 AND t.article_id IS NOT NULL)`) + +func (d *DB) ForumCommentCounts(ctx context.Context, siteID int64) (ForumCounts, error) { + var c ForumCounts + if err := d.pool.QueryRow(ctx, qForumCommentCounts, siteID).Scan(&c.Threads, &c.Posts); err != nil { + return ForumCounts{}, fmt.Errorf("count forum comments: %w", err) + } + return c, nil +} + +const forumLastPostColumns = `p.id, p.created_at, t.id, t.name, t.category_id, t.article_id, p.author_id` + +var qForumCategoryLastPost = register("ForumCategoryLastPost", ` +SELECT `+forumLastPostColumns+` +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +WHERE t.category_id = $1 +ORDER BY p.created_at DESC +LIMIT 1`) + +func (d *DB) ForumCategoryLastPost(ctx context.Context, categoryID int64) (*ForumLastPost, error) { + return d.scanLastPost(ctx, qForumCategoryLastPost, categoryID) +} + +var qForumCommentLastPost = register("ForumCommentLastPost", ` +SELECT `+forumLastPostColumns+` +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +WHERE t.site_id = $1 AND t.article_id IS NOT NULL +ORDER BY p.created_at DESC +LIMIT 1`) + +func (d *DB) ForumCommentLastPost(ctx context.Context, siteID int64) (*ForumLastPost, error) { + return d.scanLastPost(ctx, qForumCommentLastPost, siteID) +} + +func (d *DB) scanLastPost(ctx context.Context, sql string, args ...any) (*ForumLastPost, error) { + var p ForumLastPost + err := d.pool.QueryRow(ctx, sql, args...).Scan( + &p.ID, &p.CreatedAt, &p.ThreadID, &p.ThreadName, &p.ThreadCategoryID, &p.ThreadArticleID, &p.AuthorID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query last forum post: %w", err) + } + return &p, nil +} + +type ForumThread struct { + ID int64 + Name string + Description string + CategoryID *int64 + ArticleID *int64 + AuthorID *int64 + IsPinned bool + IsLocked bool + CreatedAt time.Time +} + +type ForumThreadSort int + +const ( + ForumThreadsByReply ForumThreadSort = iota + ForumThreadsByStart +) + +const forumThreadColumns = `id, name, description, category_id, article_id, author_id, is_pinned, is_locked, created_at` + +var qForumThreadsByReply = register("ForumThreadsByReply", ` +SELECT `+forumThreadColumns+` +FROM web_forumthread +WHERE category_id = $1 +ORDER BY is_pinned DESC, updated_at DESC +OFFSET $2 LIMIT $3`) + +var qForumThreadsByStart = register("ForumThreadsByStart", ` +SELECT `+forumThreadColumns+` +FROM web_forumthread +WHERE category_id = $1 +ORDER BY is_pinned DESC, created_at DESC +OFFSET $2 LIMIT $3`) + +var qForumCommentThreadsByReply = register("ForumCommentThreadsByReply", ` +SELECT `+forumThreadColumns+` +FROM web_forumthread +WHERE site_id = $1 AND article_id IS NOT NULL +ORDER BY is_pinned DESC, updated_at DESC +OFFSET $2 LIMIT $3`) + +var qForumCommentThreadsByStart = register("ForumCommentThreadsByStart", ` +SELECT `+forumThreadColumns+` +FROM web_forumthread +WHERE site_id = $1 AND article_id IS NOT NULL +ORDER BY is_pinned DESC, created_at DESC +OFFSET $2 LIMIT $3`) + +func (d *DB) ForumThreads(ctx context.Context, categoryID int64, sort ForumThreadSort, offset, limit int) ([]ForumThread, error) { + sql, args := qForumThreadsByReply, []any{categoryID, offset, limit} + if sort == ForumThreadsByStart { + sql = qForumThreadsByStart + } + return d.scanThreads(ctx, sql, args...) +} + +func (d *DB) ForumCommentThreads(ctx context.Context, siteID int64, sort ForumThreadSort, offset, limit int) ([]ForumThread, error) { + sql := qForumCommentThreadsByReply + if sort == ForumThreadsByStart { + sql = qForumCommentThreadsByStart + } + return d.scanThreads(ctx, sql, siteID, offset, limit) +} + +func (d *DB) scanThreads(ctx context.Context, sql string, args ...any) ([]ForumThread, error) { + rows, err := d.pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query forum threads: %w", err) + } + defer rows.Close() + + var out []ForumThread + for rows.Next() { + var t ForumThread + if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CategoryID, &t.ArticleID, + &t.AuthorID, &t.IsPinned, &t.IsLocked, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("scan forum thread: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} + +// Pinned threads keep their place in date order, unlike a category listing. A +// news column would otherwise open with an old item. +var qForumThreadsInCategories = register("ForumThreadsInCategories", ` +SELECT `+forumThreadColumns+` +FROM web_forumthread +WHERE category_id = ANY($1) +ORDER BY created_at DESC, id DESC +OFFSET $2 LIMIT $3`) + +func (d *DB) ForumThreadsInCategories(ctx context.Context, categoryIDs []int64, offset, limit int) ([]ForumThread, error) { + return d.scanThreads(ctx, qForumThreadsInCategories, categoryIDs, offset, limit) +} + +var qForumFirstPosts = register("ForumFirstPosts", ` +SELECT DISTINCT ON (thread_id) `+forumPostColumns+` +FROM web_forumpost +WHERE thread_id = ANY($1) AND reply_to_id IS NULL +ORDER BY thread_id, created_at`) + +func (d *DB) ForumFirstPosts(ctx context.Context, threadIDs []int64) (map[int64]ForumThreadPost, error) { + posts, err := d.scanPosts(ctx, qForumFirstPosts, threadIDs) + if err != nil { + return nil, err + } + out := make(map[int64]ForumThreadPost, len(posts)) + for _, post := range posts { + out[post.ThreadID] = post + } + return out, nil +} + +var qForumThreadPostCounts = register("ForumThreadPostCounts", ` +SELECT thread_id, count(*) +FROM web_forumpost +WHERE thread_id = ANY($1) +GROUP BY thread_id`) + +func (d *DB) ForumThreadPostCounts(ctx context.Context, threadIDs []int64) (map[int64]int, error) { + rows, err := d.pool.Query(ctx, qForumThreadPostCounts, threadIDs) + if err != nil { + return nil, fmt.Errorf("count posts per thread: %w", err) + } + defer rows.Close() + + out := make(map[int64]int, len(threadIDs)) + for rows.Next() { + var id, count int64 + if err := rows.Scan(&id, &count); err != nil { + return nil, fmt.Errorf("scan thread post count: %w", err) + } + out[id] = int(count) + } + return out, rows.Err() +} + +var qForumThreadPostCount = register("ForumThreadPostCount", ` +SELECT count(*) FROM web_forumpost WHERE thread_id = $1`) + +func (d *DB) ForumThreadPostCount(ctx context.Context, threadID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qForumThreadPostCount, threadID).Scan(&n); err != nil { + return 0, fmt.Errorf("count posts in thread %d: %w", threadID, err) + } + return n, nil +} + +var qForumThreadLastPost = register("ForumThreadLastPost", ` +SELECT id, created_at, author_id +FROM web_forumpost +WHERE thread_id = $1 +ORDER BY created_at +OFFSET $2 LIMIT 1`) + +type ForumPost struct { + ID int64 + CreatedAt time.Time + AuthorID *int64 +} + +func (d *DB) ForumThreadLastPost(ctx context.Context, threadID int64, count int) (*ForumPost, error) { + var p ForumPost + err := d.pool.QueryRow(ctx, qForumThreadLastPost, threadID, count-1).Scan(&p.ID, &p.CreatedAt, &p.AuthorID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query last post in thread %d: %w", threadID, err) + } + return &p, nil +} + +var qForumCategory = register("ForumCategory", ` +SELECT id, section_id, name, description, is_for_comments +FROM web_forumcategory +WHERE id = $1`) + +func (d *DB) ForumCategory(ctx context.Context, id int64) (*ForumCategory, error) { + var c ForumCategory + err := d.pool.QueryRow(ctx, qForumCategory, id).Scan( + &c.ID, &c.SectionID, &c.Name, &c.Description, &c.IsForComments) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query forum category %d: %w", id, err) + } + return &c, nil +} + +var qForumThread = register("ForumThread", ` +SELECT `+forumThreadColumns+` +FROM web_forumthread +WHERE id = $1`) + +func (d *DB) ForumThread(ctx context.Context, id int64) (*ForumThread, error) { + var t ForumThread + err := d.pool.QueryRow(ctx, qForumThread, id).Scan(&t.ID, &t.Name, &t.Description, + &t.CategoryID, &t.ArticleID, &t.AuthorID, &t.IsPinned, &t.IsLocked, &t.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query forum thread %d: %w", id, err) + } + return &t, nil +} + +type ForumThreadPost struct { + ID int64 + ThreadID int64 + Name string + CreatedAt time.Time + UpdatedAt time.Time + AuthorID *int64 + ReplyToID *int64 +} + +const forumPostColumns = `id, thread_id, name, created_at, updated_at, author_id, reply_to_id` + +var qForumRootPostCount = register("ForumRootPostCount", ` +SELECT count(*) FROM web_forumpost WHERE thread_id = $1 AND reply_to_id IS NULL`) + +func (d *DB) ForumRootPostCount(ctx context.Context, threadID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qForumRootPostCount, threadID).Scan(&n); err != nil { + return 0, fmt.Errorf("count root posts in thread %d: %w", threadID, err) + } + return n, nil +} + +var qForumRootPosts = register("ForumRootPosts", ` +SELECT `+forumPostColumns+` +FROM web_forumpost +WHERE thread_id = $1 AND reply_to_id IS NULL +ORDER BY created_at +OFFSET $2 LIMIT $3`) + +func (d *DB) ForumRootPosts(ctx context.Context, threadID int64, offset, limit int) ([]ForumThreadPost, error) { + return d.scanPosts(ctx, qForumRootPosts, threadID, offset, limit) +} + +var qForumRootPostIDs = register("ForumRootPostIDs", ` +SELECT id +FROM web_forumpost +WHERE thread_id = $1 AND reply_to_id IS NULL +ORDER BY created_at`) + +func (d *DB) ForumRootPostIDs(ctx context.Context, threadID int64) ([]int64, error) { + rows, err := d.pool.Query(ctx, qForumRootPostIDs, threadID) + if err != nil { + return nil, fmt.Errorf("query root post ids of thread %d: %w", threadID, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan root post id: %w", err) + } + out = append(out, id) + } + return out, rows.Err() +} + +var qForumPostReplies = register("ForumPostReplies", ` +SELECT `+forumPostColumns+` +FROM web_forumpost +WHERE reply_to_id = $1 +ORDER BY created_at`) + +func (d *DB) ForumPostReplies(ctx context.Context, postID int64) ([]ForumThreadPost, error) { + return d.scanPosts(ctx, qForumPostReplies, postID) +} + +var qForumPost = register("ForumPost", ` +SELECT `+forumPostColumns+` +FROM web_forumpost +WHERE id = $1`) + +func (d *DB) ForumPost(ctx context.Context, id int64) (*ForumThreadPost, error) { + var p ForumThreadPost + err := d.pool.QueryRow(ctx, qForumPost, id).Scan(&p.ID, &p.ThreadID, &p.Name, + &p.CreatedAt, &p.UpdatedAt, &p.AuthorID, &p.ReplyToID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("query forum post %d: %w", id, err) + } + return &p, nil +} + +func (d *DB) scanPosts(ctx context.Context, sql string, args ...any) ([]ForumThreadPost, error) { + rows, err := d.pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query forum posts: %w", err) + } + defer rows.Close() + + var out []ForumThreadPost + for rows.Next() { + var p ForumThreadPost + if err := rows.Scan(&p.ID, &p.ThreadID, &p.Name, &p.CreatedAt, &p.UpdatedAt, + &p.AuthorID, &p.ReplyToID); err != nil { + return nil, fmt.Errorf("scan forum post: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +type ForumPostContent struct { + Source string + AuthorID *int64 +} + +var qForumPostContents = register("ForumPostContents", ` +SELECT DISTINCT ON (post_id) post_id, source, author_id +FROM web_forumpostversion +WHERE post_id = ANY($1) +ORDER BY post_id, created_at DESC`) + +func (d *DB) ForumPostContents(ctx context.Context, postIDs []int64) (map[int64]ForumPostContent, error) { + rows, err := d.pool.Query(ctx, qForumPostContents, postIDs) + if err != nil { + return nil, fmt.Errorf("query forum post contents: %w", err) + } + defer rows.Close() + + out := make(map[int64]ForumPostContent, len(postIDs)) + for rows.Next() { + var id int64 + var content ForumPostContent + if err := rows.Scan(&id, &content.Source, &content.AuthorID); err != nil { + return nil, fmt.Errorf("scan forum post content: %w", err) + } + out[id] = content + } + return out, rows.Err() +} + +type RecentPost struct { + ID int64 + Name string + CreatedAt time.Time + AuthorID *int64 + + ThreadID int64 + ThreadName string + ThreadCategoryID *int64 + ThreadArticleID *int64 + ThreadAuthorID *int64 +} + +// An empty category list with comments switched off matches nothing, which is +// what a reader who may see no category is meant to get. +const recentPostFilter = ` +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +WHERE t.category_id = ANY($1) OR ($2::boolean AND t.article_id IS NOT NULL)` + +var qRecentPostCount = register("RecentPostCount", `SELECT count(*)`+recentPostFilter) + +func (d *DB) RecentPostCount(ctx context.Context, categoryIDs []int64, comments bool) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qRecentPostCount, categoryIDs, comments).Scan(&n); err != nil { + return 0, fmt.Errorf("count recent posts: %w", err) + } + return n, nil +} + +var qRecentPosts = register("RecentPosts", ` +SELECT p.id, p.name, p.created_at, p.author_id, + t.id, t.name, t.category_id, t.article_id, t.author_id`+recentPostFilter+` +ORDER BY p.created_at DESC +OFFSET $3 LIMIT $4`) + +func (d *DB) RecentPosts(ctx context.Context, categoryIDs []int64, comments bool, offset, limit int) ([]RecentPost, error) { + rows, err := d.pool.Query(ctx, qRecentPosts, categoryIDs, comments, offset, limit) + if err != nil { + return nil, fmt.Errorf("query recent posts: %w", err) + } + defer rows.Close() + + var out []RecentPost + for rows.Next() { + var p RecentPost + if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt, &p.AuthorID, + &p.ThreadID, &p.ThreadName, &p.ThreadCategoryID, &p.ThreadArticleID, + &p.ThreadAuthorID); err != nil { + return nil, fmt.Errorf("scan recent post: %w", err) + } + out = append(out, p) + } + return out, rows.Err() +} + +type UserPost struct { + ID int64 + Name string + CreatedAt time.Time + + ThreadID int64 + ThreadName string + ThreadCategoryID *int64 + + ArticleTitle *string + ArticleName *string + ArticleCategory *string +} + +// The same visibility rule the recent-post listing uses, so one reader never +// sees a post on the profile that the forum would have kept from them. +var qUserPosts = register("UserPosts", ` +SELECT p.id, p.name, p.created_at, + t.id, t.name, t.category_id, + a.title, a.name, a.category +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +LEFT JOIN web_article a ON a.id = t.article_id +WHERE p.author_id = $1 + AND (t.category_id = ANY($2) OR ($3::boolean AND t.article_id IS NOT NULL)) +ORDER BY p.created_at DESC +OFFSET $4 LIMIT $5`) + +var qUserPostCount = register("UserPostCount", ` +SELECT count(*) +FROM web_forumpost p +JOIN web_forumthread t ON t.id = p.thread_id +WHERE p.author_id = $1 + AND (t.category_id = ANY($2) OR ($3::boolean AND t.article_id IS NOT NULL))`) + +func (d *DB) UserPostCount(ctx context.Context, authorID int64, categoryIDs []int64, comments bool) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qUserPostCount, authorID, categoryIDs, comments).Scan(&n); err != nil { + return 0, fmt.Errorf("count posts of user %d: %w", authorID, err) + } + return n, nil +} + +func (d *DB) UserPosts(ctx context.Context, authorID int64, categoryIDs []int64, + comments bool, offset, limit int) ([]UserPost, error) { + + rows, err := d.pool.Query(ctx, qUserPosts, authorID, categoryIDs, comments, offset, limit) + if err != nil { + return nil, fmt.Errorf("query posts of user %d: %w", authorID, err) + } + defer rows.Close() + + var out []UserPost + for rows.Next() { + var p UserPost + if err := rows.Scan(&p.ID, &p.Name, &p.CreatedAt, + &p.ThreadID, &p.ThreadName, &p.ThreadCategoryID, + &p.ArticleTitle, &p.ArticleName, &p.ArticleCategory); err != nil { + return nil, fmt.Errorf("scan post of user %d: %w", authorID, err) + } + out = append(out, p) + } + return out, rows.Err() +} diff --git a/internal/db/forum_admin.go b/internal/db/forum_admin.go new file mode 100644 index 00000000..978adae7 --- /dev/null +++ b/internal/db/forum_admin.go @@ -0,0 +1,187 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type ForumSectionRow struct { + ID int64 + Name string + Description string + Order int + IsHidden bool + IsHiddenForUser bool + Categories int +} + +var qAdminForumSections = register("AdminForumSections", ` +SELECT s.id, s.name, s.description, s."order", s.is_hidden, s.is_hidden_for_users, + (SELECT count(*) FROM web_forumcategory c WHERE c.section_id = s.id) +FROM web_forumsection s WHERE s.site_id = $1 ORDER BY s."order", s.id`) + +func (d *DB) AdminForumSections(ctx context.Context, siteID int64) ([]ForumSectionRow, error) { + rows, err := d.pool.Query(ctx, qAdminForumSections, siteID) + if err != nil { + return nil, fmt.Errorf("list forum sections: %w", err) + } + defer rows.Close() + + var out []ForumSectionRow + for rows.Next() { + var s ForumSectionRow + if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Order, &s.IsHidden, &s.IsHiddenForUser, &s.Categories); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +var qAdminForumSection = register("AdminForumSection", ` +SELECT id, name, description, "order", is_hidden, is_hidden_for_users +FROM web_forumsection WHERE id = $1 AND site_id = $2`) + +func (d *DB) AdminForumSection(ctx context.Context, siteID, id int64) (ForumSectionRow, error) { + var s ForumSectionRow + err := d.pool.QueryRow(ctx, qAdminForumSection, id, siteID). + Scan(&s.ID, &s.Name, &s.Description, &s.Order, &s.IsHidden, &s.IsHiddenForUser) + if errors.Is(err, pgx.ErrNoRows) { + return ForumSectionRow{}, ErrNotFound + } + if err != nil { + return ForumSectionRow{}, fmt.Errorf("read forum section %d: %w", id, err) + } + return s, nil +} + +var ( + qInsertForumSection = register("InsertForumSection", ` +INSERT INTO web_forumsection (name, description, "order", is_hidden, is_hidden_for_users, site_id) +VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`) + + qUpdateForumSection = register("UpdateForumSection", ` +UPDATE web_forumsection SET name=$2, description=$3, "order"=$4, is_hidden=$5, is_hidden_for_users=$6 +WHERE id=$1 AND site_id=$7`) + + qDeleteForumSection = register("DeleteForumSection", `DELETE FROM web_forumsection WHERE id = $1 AND site_id = $2`) +) + +func (d *DB) SaveForumSection(ctx context.Context, siteID int64, s ForumSectionRow) error { + if s.ID == 0 { + var id int64 + err := d.pool.QueryRow(ctx, qInsertForumSection, s.Name, s.Description, s.Order, s.IsHidden, s.IsHiddenForUser, siteID).Scan(&id) + if err != nil { + return fmt.Errorf("create forum section %q: %w", s.Name, err) + } + return nil + } + _, err := d.pool.Exec(ctx, qUpdateForumSection, s.ID, s.Name, s.Description, s.Order, s.IsHidden, s.IsHiddenForUser, siteID) + if err != nil { + return fmt.Errorf("update forum section %d: %w", s.ID, err) + } + return nil +} + +func (d *DB) DeleteForumSection(ctx context.Context, siteID, id int64) error { + if _, err := d.pool.Exec(ctx, qDeleteForumSection, id, siteID); err != nil { + return fmt.Errorf("delete forum section %d: %w", id, err) + } + return nil +} + +type ForumCategoryRow struct { + ID int64 + Name string + Description string + Order int + IsForComments bool + SectionID int64 + SectionName string + Threads int +} + +var qAdminForumCategories = register("AdminForumCategories", ` +SELECT c.id, c.name, c.description, c."order", c.is_for_comments, c.section_id, s.name, + (SELECT count(*) FROM web_forumthread t WHERE t.category_id = c.id) +FROM web_forumcategory c JOIN web_forumsection s ON s.id = c.section_id +WHERE s.site_id = $1 +ORDER BY s."order", c."order", c.id`) + +func (d *DB) AdminForumCategories(ctx context.Context, siteID int64) ([]ForumCategoryRow, error) { + rows, err := d.pool.Query(ctx, qAdminForumCategories, siteID) + if err != nil { + return nil, fmt.Errorf("list forum categories: %w", err) + } + defer rows.Close() + + var out []ForumCategoryRow + for rows.Next() { + var c ForumCategoryRow + if err := rows.Scan(&c.ID, &c.Name, &c.Description, &c.Order, &c.IsForComments, &c.SectionID, &c.SectionName, &c.Threads); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +var qAdminForumCategory = register("AdminForumCategory", ` +SELECT id, name, description, "order", is_for_comments, section_id +FROM web_forumcategory WHERE id = $1 +AND section_id IN (SELECT id FROM web_forumsection WHERE site_id = $2)`) + +func (d *DB) AdminForumCategory(ctx context.Context, siteID, id int64) (ForumCategoryRow, error) { + var c ForumCategoryRow + err := d.pool.QueryRow(ctx, qAdminForumCategory, id, siteID). + Scan(&c.ID, &c.Name, &c.Description, &c.Order, &c.IsForComments, &c.SectionID) + if errors.Is(err, pgx.ErrNoRows) { + return ForumCategoryRow{}, ErrNotFound + } + if err != nil { + return ForumCategoryRow{}, fmt.Errorf("read forum category %d: %w", id, err) + } + return c, nil +} + +var ( + qInsertForumCategory = register("InsertForumCategory", ` +INSERT INTO web_forumcategory (name, description, "order", is_for_comments, section_id) +SELECT $1, $2, $3, $4, $5 +WHERE EXISTS (SELECT 1 FROM web_forumsection WHERE id = $5 AND site_id = $6) +RETURNING id`) + + qUpdateForumCategory = register("UpdateForumCategory", ` +UPDATE web_forumcategory SET name=$2, description=$3, "order"=$4, is_for_comments=$5, section_id=$6 +WHERE id=$1 +AND section_id IN (SELECT id FROM web_forumsection WHERE site_id = $7)`) + + qDeleteForumCategory = register("DeleteForumCategory", `DELETE FROM web_forumcategory WHERE id = $1 +AND section_id IN (SELECT id FROM web_forumsection WHERE site_id = $2)`) +) + +func (d *DB) SaveForumCategory(ctx context.Context, siteID int64, c ForumCategoryRow) error { + if c.ID == 0 { + var id int64 + err := d.pool.QueryRow(ctx, qInsertForumCategory, c.Name, c.Description, c.Order, c.IsForComments, c.SectionID, siteID).Scan(&id) + if err != nil { + return fmt.Errorf("create forum category %q: %w", c.Name, err) + } + return nil + } + _, err := d.pool.Exec(ctx, qUpdateForumCategory, c.ID, c.Name, c.Description, c.Order, c.IsForComments, c.SectionID, siteID) + if err != nil { + return fmt.Errorf("update forum category %d: %w", c.ID, err) + } + return nil +} + +func (d *DB) DeleteForumCategory(ctx context.Context, siteID, id int64) error { + if _, err := d.pool.Exec(ctx, qDeleteForumCategory, id, siteID); err != nil { + return fmt.Errorf("delete forum category %d: %w", id, err) + } + return nil +} diff --git a/internal/db/forum_test.go b/internal/db/forum_test.go new file mode 100644 index 00000000..a72c2bb2 --- /dev/null +++ b/internal/db/forum_test.go @@ -0,0 +1,296 @@ +package db + +import ( + "context" + "errors" + "os" + "testing" + "time" +) + +func forumDB(t *testing.T) (*DB, context.Context) { + t.Helper() + dsn := os.Getenv(EnvDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the database test", EnvDSN) + } + ctx := context.Background() + d, err := Open(ctx, dsn) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + t.Cleanup(d.Close) + return d, ctx +} + +func TestForumSectionsComeBackInOrder(t *testing.T) { + d, ctx := forumDB(t) + sections, err := d.ForumSections(ctx, seedSiteID(t, d)) + if err != nil { + t.Fatalf("ForumSections() err = %v, want nil", err) + } + + want := []struct { + name string + hidden bool + hiddenForUsers bool + }{ + {"Probe Open", false, false}, + {"Probe Hidden", true, false}, + {"Probe Staff", false, true}, + } + if len(sections) != len(want) { + t.Fatalf("len(ForumSections()) = %d, want %d", len(sections), len(want)) + } + for i, w := range want { + if sections[i].Name != w.name { + t.Errorf("ForumSections()[%d].Name = %q, want %q", i, sections[i].Name, w.name) + } + if sections[i].IsHidden != w.hidden { + t.Errorf("ForumSections()[%d].IsHidden = %t, want %t", i, sections[i].IsHidden, w.hidden) + } + if sections[i].IsHiddenForUsers != w.hiddenForUsers { + t.Errorf("ForumSections()[%d].IsHiddenForUsers = %t, want %t", i, sections[i].IsHiddenForUsers, w.hiddenForUsers) + } + } +} + +func TestForumSectionByID(t *testing.T) { + d, ctx := forumDB(t) + sections, err := d.ForumSections(ctx, seedSiteID(t, d)) + if err != nil { + t.Fatalf("ForumSections() err = %v, want nil", err) + } + got, err := d.ForumSection(ctx, sections[0].ID) + if err != nil { + t.Fatalf("ForumSection(%d) err = %v, want nil", sections[0].ID, err) + } + if got.Name != sections[0].Name { + t.Errorf("ForumSection(%d).Name = %q, want %q", sections[0].ID, got.Name, sections[0].Name) + } +} + +func TestForumSectionOfAnUnknownID(t *testing.T) { + d, ctx := forumDB(t) + if _, err := d.ForumSection(ctx, -1); !errors.Is(err, ErrNotFound) { + t.Errorf("ForumSection(-1) err = %v, want ErrNotFound", err) + } +} + +func TestForumCategoriesComeBackInOrder(t *testing.T) { + d, ctx := forumDB(t) + categories, err := d.ForumCategories(ctx, seedSiteID(t, d)) + if err != nil { + t.Fatalf("ForumCategories() err = %v, want nil", err) + } + + want := []string{"Probe Chat", "Probe Hidden Chat", "Probe Staff Chat", "Probe Comments", "Probe Quiet", "Probe Busy", "Probe Talk"} + if len(categories) != len(want) { + t.Fatalf("len(ForumCategories()) = %d, want %d", len(categories), len(want)) + } + for i, name := range want { + if categories[i].Name != name { + t.Errorf("ForumCategories()[%d].Name = %q, want %q", i, categories[i].Name, name) + } + } +} + +func categoryNamed(t *testing.T, d *DB, ctx context.Context, name string) ForumCategory { + t.Helper() + categories, err := d.ForumCategories(ctx, seedSiteID(t, d)) + if err != nil { + t.Fatalf("ForumCategories() err = %v, want nil", err) + } + for _, c := range categories { + if c.Name == name { + return c + } + } + t.Fatalf("ForumCategories() has no %q, want it", name) + return ForumCategory{} +} + +func TestForumCategoryCounts(t *testing.T) { + d, ctx := forumDB(t) + chat := categoryNamed(t, d, ctx, "Probe Chat") + got, err := d.ForumCategoryCounts(ctx, chat.ID) + if err != nil { + t.Fatalf("ForumCategoryCounts(chat) err = %v, want nil", err) + } + if got.Threads != 3 { + t.Errorf("ForumCategoryCounts(chat).Threads = %d, want %d", got.Threads, 3) + } + if got.Posts != 6 { + t.Errorf("ForumCategoryCounts(chat).Posts = %d, want %d", got.Posts, 6) + } +} + +func TestForumCategoryCountsOfAnEmptyCategory(t *testing.T) { + d, ctx := forumDB(t) + quiet := categoryNamed(t, d, ctx, "Probe Quiet") + got, err := d.ForumCategoryCounts(ctx, quiet.ID) + if err != nil { + t.Fatalf("ForumCategoryCounts(quiet) err = %v, want nil", err) + } + if got.Threads != 0 || got.Posts != 0 { + t.Errorf("ForumCategoryCounts(quiet) = %+v, want zeroes", got) + } +} + +func TestForumCategoryLastPost(t *testing.T) { + d, ctx := forumDB(t) + chat := categoryNamed(t, d, ctx, "Probe Chat") + got, err := d.ForumCategoryLastPost(ctx, chat.ID) + if err != nil { + t.Fatalf("ForumCategoryLastPost(chat) err = %v, want nil", err) + } + if got.ThreadName != "Probe Pinned Thread" { + t.Errorf("ForumCategoryLastPost(chat).ThreadName = %q, want %q", got.ThreadName, "Probe Pinned Thread") + } + want := time.Date(2023, 9, 10, 11, 17, 13, 0, time.UTC) + if !got.CreatedAt.Equal(want) { + t.Errorf("ForumCategoryLastPost(chat).CreatedAt = %v, want %v", got.CreatedAt.UTC(), want) + } + if got.ThreadArticleID != nil { + t.Errorf("ForumCategoryLastPost(chat).ThreadArticleID = %v, want nil", *got.ThreadArticleID) + } + if got.AuthorID == nil { + t.Error("ForumCategoryLastPost(chat).AuthorID = nil, want an id") + } +} + +func TestForumCategoryLastPostOfAnEmptyCategory(t *testing.T) { + d, ctx := forumDB(t) + quiet := categoryNamed(t, d, ctx, "Probe Quiet") + if _, err := d.ForumCategoryLastPost(ctx, quiet.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("ForumCategoryLastPost(quiet) err = %v, want ErrNotFound", err) + } +} + +func TestForumCommentCountsReachEveryArticle(t *testing.T) { + d, ctx := forumDB(t) + comments := categoryNamed(t, d, ctx, "Probe Comments") + byCategory, err := d.ForumCategoryCounts(ctx, comments.ID) + if err != nil { + t.Fatalf("ForumCategoryCounts(comments) err = %v, want nil", err) + } + if byCategory.Threads != 0 || byCategory.Posts != 0 { + t.Errorf("ForumCategoryCounts(comments) = %+v, want zeroes", byCategory) + } + + got, err := d.ForumCommentCounts(ctx, seedSiteID(t, d)) + if err != nil { + t.Fatalf("ForumCommentCounts() err = %v, want nil", err) + } + if got.Threads < 1 { + t.Errorf("ForumCommentCounts().Threads = %d, want at least 1", got.Threads) + } + if got.Posts != 4 { + t.Errorf("ForumCommentCounts().Posts = %d, want %d", got.Posts, 4) + } +} + +func TestForumCommentLastPostComesFromAnArticleThread(t *testing.T) { + d, ctx := forumDB(t) + got, err := d.ForumCommentLastPost(ctx, seedSiteID(t, d)) + if err != nil { + t.Fatalf("ForumCommentLastPost() err = %v, want nil", err) + } + if got.ThreadArticleID == nil { + t.Error("ForumCommentLastPost().ThreadArticleID = nil, want an id") + } + if got.ThreadName != "Probe Full" { + t.Errorf("ForumCommentLastPost().ThreadName = %q, want %q", got.ThreadName, "Probe Full") + } +} + +func TestUserByIDRoundTrips(t *testing.T) { + d, ctx := forumDB(t) + want, err := d.UserByUsername(ctx, "probe-author") + if err != nil { + t.Fatalf("UserByUsername(probe-author) err = %v, want nil", err) + } + got, err := d.UserByID(ctx, want.ID) + if err != nil { + t.Fatalf("UserByID(%d) err = %v, want nil", want.ID, err) + } + if got.Username != want.Username { + t.Errorf("UserByID(%d).Username = %q, want %q", want.ID, got.Username, want.Username) + } +} + +func TestUserByIDOfAnUnknownID(t *testing.T) { + d, ctx := forumDB(t) + if _, err := d.UserByID(ctx, -1); !errors.Is(err, ErrNotFound) { + t.Errorf("UserByID(-1) err = %v, want ErrNotFound", err) + } +} + +func TestForumThreadsInCategoriesKeepDateOrderAcrossCategories(t *testing.T) { + d, ctx := forumDB(t) + threads, err := d.ForumThreadsInCategories(ctx, []int64{55, 61}, 0, 20) + if err != nil { + t.Fatalf("ForumThreadsInCategories() err = %v, want nil", err) + } + + want := []string{"Probe Long Thread", "Probe Deep Thread", "Probe Pinned Thread", "Probe Locked Thread", "Probe Thread"} + if len(threads) != len(want) { + t.Fatalf("len(ForumThreadsInCategories()) = %d, want %d", len(threads), len(want)) + } + for i, name := range want { + if threads[i].Name != name { + t.Errorf("ForumThreadsInCategories()[%d].Name = %q, want %q", i, threads[i].Name, name) + } + } +} + +func TestForumThreadsInCategoriesWindow(t *testing.T) { + d, ctx := forumDB(t) + threads, err := d.ForumThreadsInCategories(ctx, []int64{55, 61}, 2, 2) + if err != nil { + t.Fatalf("ForumThreadsInCategories() err = %v, want nil", err) + } + + want := []string{"Probe Pinned Thread", "Probe Locked Thread"} + if len(threads) != len(want) { + t.Fatalf("len(ForumThreadsInCategories()) = %d, want %d", len(threads), len(want)) + } + for i, name := range want { + if threads[i].Name != name { + t.Errorf("ForumThreadsInCategories()[%d].Name = %q, want %q", i, threads[i].Name, name) + } + } +} + +func TestForumFirstPostsPickTheOldestRootPost(t *testing.T) { + d, ctx := forumDB(t) + first, err := d.ForumFirstPosts(ctx, []int64{97, 122}) + if err != nil { + t.Fatalf("ForumFirstPosts() err = %v, want nil", err) + } + + want := map[int64]int64{97: 22, 122: 54} + if len(first) != len(want) { + t.Fatalf("len(ForumFirstPosts()) = %d, want %d", len(first), len(want)) + } + for thread, post := range want { + if first[thread].ID != post { + t.Errorf("ForumFirstPosts()[%d].ID = %d, want %d", thread, first[thread].ID, post) + } + } +} + +func TestForumThreadPostCounts(t *testing.T) { + d, ctx := forumDB(t) + counts, err := d.ForumThreadPostCounts(ctx, []int64{97, 122}) + if err != nil { + t.Fatalf("ForumThreadPostCounts() err = %v, want nil", err) + } + + want := map[int64]int{97: 3, 122: 12} + for thread, count := range want { + if counts[thread] != count { + t.Errorf("ForumThreadPostCounts()[%d] = %d, want %d", thread, counts[thread], count) + } + } +} diff --git a/internal/db/forum_write.go b/internal/db/forum_write.go new file mode 100644 index 00000000..57b97856 --- /dev/null +++ b/internal/db/forum_write.go @@ -0,0 +1,281 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type ForumPostVersion struct { + CreatedAt time.Time + AuthorID *int64 +} + +var qForumPostVersions = register("ForumPostVersions", ` +SELECT created_at, author_id +FROM web_forumpostversion +WHERE post_id = $1 +ORDER BY created_at DESC`) + +func (d *DB) ForumPostVersions(ctx context.Context, postID int64) ([]ForumPostVersion, error) { + rows, err := d.pool.Query(ctx, qForumPostVersions, postID) + if err != nil { + return nil, fmt.Errorf("list versions of post %d: %w", postID, err) + } + defer rows.Close() + + var out []ForumPostVersion + for rows.Next() { + var v ForumPostVersion + if err := rows.Scan(&v.CreatedAt, &v.AuthorID); err != nil { + return nil, fmt.Errorf("scan post version: %w", err) + } + out = append(out, v) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list versions of post %d: %w", postID, err) + } + return out, nil +} + +var ( + qForumPostSource = register("ForumPostSource", ` +SELECT source FROM web_forumpostversion +WHERE post_id = $1 +ORDER BY created_at DESC +LIMIT 1`) + + qForumPostSourceAt = register("ForumPostSourceAt", ` +SELECT source FROM web_forumpostversion +WHERE post_id = $1 AND created_at <= $2 +ORDER BY created_at DESC +LIMIT 1`) +) + +// A post with no version behind it reads as empty rather than as missing, which +// is the same thing the thread page shows for it. +func (d *DB) ForumPostSource(ctx context.Context, postID int64, at *time.Time) (string, error) { + var source string + var err error + if at == nil { + err = d.pool.QueryRow(ctx, qForumPostSource, postID).Scan(&source) + } else { + err = d.pool.QueryRow(ctx, qForumPostSourceAt, postID, *at).Scan(&source) + } + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read source of post %d: %w", postID, err) + } + return source, nil +} + +type ForumPostWrite struct { + ThreadID int64 + Name string + Source string + AuthorID *int64 + ReplyToID *int64 + At time.Time +} + +var ( + qInsertForumPost = register("InsertForumPost", ` +INSERT INTO web_forumpost (thread_id, name, author_id, reply_to_id, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $5) +RETURNING id`) + + qInsertForumPostVersion = register("InsertForumPostVersion", ` +INSERT INTO web_forumpostversion (post_id, source, author_id, created_at) +VALUES ($1, $2, $3, $4)`) + + qTouchForumThread = register("TouchForumThread", ` +UPDATE web_forumthread SET updated_at = $2 WHERE id = $1`) +) + +// The post, its first version and the thread's timestamp move together, so a +// post that fails halfway leaves no body-less row behind. +func (d *DB) CreateForumPost(ctx context.Context, w ForumPostWrite) (int64, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin post: %w", err) + } + defer tx.Rollback(ctx) + + var id int64 + if err := tx.QueryRow(ctx, qInsertForumPost, w.ThreadID, w.Name, w.AuthorID, + w.ReplyToID, w.At).Scan(&id); err != nil { + return 0, fmt.Errorf("write post in thread %d: %w", w.ThreadID, err) + } + if _, err := tx.Exec(ctx, qInsertForumPostVersion, id, w.Source, w.AuthorID, w.At); err != nil { + return 0, fmt.Errorf("write first version of post %d: %w", id, err) + } + if _, err := tx.Exec(ctx, qTouchForumThread, w.ThreadID, w.At); err != nil { + return 0, fmt.Errorf("touch thread %d: %w", w.ThreadID, err) + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit post in thread %d: %w", w.ThreadID, err) + } + return id, nil +} + +var qRenameForumPost = register("RenameForumPost", ` +UPDATE web_forumpost SET name = $2, updated_at = $3 WHERE id = $1`) + +// A body that did not change leaves no version, so the edit history only holds +// the times the text actually moved. +func (d *DB) UpdateForumPost(ctx context.Context, postID int64, name, source, previous string, + authorID *int64, at time.Time) error { + + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin edit of post %d: %w", postID, err) + } + defer tx.Rollback(ctx) + + if source != previous { + if _, err := tx.Exec(ctx, qInsertForumPostVersion, postID, source, authorID, at); err != nil { + return fmt.Errorf("write version of post %d: %w", postID, err) + } + } + if _, err := tx.Exec(ctx, qRenameForumPost, postID, name, at); err != nil { + return fmt.Errorf("edit post %d: %w", postID, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit edit of post %d: %w", postID, err) + } + return nil +} + +// Replies to a deleted post become roots of the thread rather than following it +// down, so a whole branch does not vanish with one post. +var forumPostChildren = []string{ + `UPDATE web_forumpost SET reply_to_id = NULL WHERE reply_to_id = $1`, + `DELETE FROM web_forumpostversion WHERE post_id = $1`, + `DELETE FROM web_forumpost WHERE id = $1`, +} + +var qDeleteForumPost = registerAll("DeleteForumPost", forumPostChildren) + +func (d *DB) DeleteForumPost(ctx context.Context, postID int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin delete of post %d: %w", postID, err) + } + defer tx.Rollback(ctx) + + for _, sql := range qDeleteForumPost { + if _, err := tx.Exec(ctx, sql, postID); err != nil { + return fmt.Errorf("delete post %d: %w", postID, err) + } + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit delete of post %d: %w", postID, err) + } + return nil +} + +type ForumThreadWrite struct { + CategoryID int64 + Name string + Description string + AuthorID *int64 + At time.Time +} + +var qInsertForumThread = register("InsertForumThread", ` +INSERT INTO web_forumthread (category_id, name, description, author_id, created_at, updated_at, is_pinned, is_locked, site_id) +VALUES ($1, $2, $3, $4, $5, $5, false, false, $6) +RETURNING id`) + +// The thread and its first post go in together, so a thread that fails halfway +// does not show up empty in the category listing. +func (d *DB) CreateForumThread(ctx context.Context, siteID int64, w ForumThreadWrite, source string) (int64, int64, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, 0, fmt.Errorf("begin thread: %w", err) + } + defer tx.Rollback(ctx) + + var threadID int64 + if err := tx.QueryRow(ctx, qInsertForumThread, w.CategoryID, w.Name, w.Description, + w.AuthorID, w.At, siteID).Scan(&threadID); err != nil { + return 0, 0, fmt.Errorf("write thread in category %d: %w", w.CategoryID, err) + } + var postID int64 + if err := tx.QueryRow(ctx, qInsertForumPost, threadID, w.Name, w.AuthorID, + nil, w.At).Scan(&postID); err != nil { + return 0, 0, fmt.Errorf("write first post of thread %d: %w", threadID, err) + } + if _, err := tx.Exec(ctx, qInsertForumPostVersion, postID, source, w.AuthorID, w.At); err != nil { + return 0, 0, fmt.Errorf("write first version of post %d: %w", postID, err) + } + if err := tx.Commit(ctx); err != nil { + return 0, 0, fmt.Errorf("commit thread in category %d: %w", w.CategoryID, err) + } + return threadID, postID, nil +} + +type ForumThreadEdit struct { + Name *string + Description *string + Locked *bool + Pinned *bool + CategoryID *int64 +} + +var qUpdateForumThread = register("UpdateForumThread", ` +UPDATE web_forumthread SET + name = COALESCE($2, name), + description = COALESCE($3, description), + is_locked = COALESCE($4, is_locked), + is_pinned = COALESCE($5, is_pinned), + category_id = COALESCE($6, category_id) +WHERE id = $1`) + +func (d *DB) UpdateForumThread(ctx context.Context, threadID int64, e ForumThreadEdit) error { + _, err := d.pool.Exec(ctx, qUpdateForumThread, threadID, + e.Name, e.Description, e.Locked, e.Pinned, e.CategoryID) + if err != nil { + return fmt.Errorf("edit thread %d: %w", threadID, err) + } + return nil +} + +var qActiveUsersByNames = register("ActiveUsersByNames", ` +SELECT `+userColumns+` +FROM web_user +WHERE lower(username) = ANY($1) AND is_active AND type IN ('normal', 'bot') +ORDER BY id`) + +// A mention names someone by the name they type, which is matched without case +// so a post that writes it differently still reaches them. +func (d *DB) ActiveUsersByNames(ctx context.Context, names []string) ([]User, error) { + if len(names) == 0 { + return nil, nil + } + rows, err := d.pool.Query(ctx, qActiveUsersByNames, names) + if err != nil { + return nil, fmt.Errorf("look up mentioned users: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + dest, finish := userDest(&u) + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan mentioned user: %w", err) + } + finish() + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read mentioned users: %w", err) + } + return out, nil +} diff --git a/internal/db/language_write_test.go b/internal/db/language_write_test.go new file mode 100644 index 00000000..b1037b86 --- /dev/null +++ b/internal/db/language_write_test.go @@ -0,0 +1,60 @@ +package db + +import ( + "context" + "testing" +) + +func TestUpdateProfileStoresTheLanguage(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + user := scratchUser(t, d, "probe-lang") + + if err := d.UpdateProfile(ctx, user, "Ada", "Lovelace", "", nil, "en"); err != nil { + t.Fatalf("UpdateProfile() err = %v, want nil", err) + } + got, err := d.UserByID(ctx, user) + if err != nil { + t.Fatalf("UserByID() err = %v, want nil", err) + } + if got.Language != "en" { + t.Errorf("UserByID().Language = %q, want %q", got.Language, "en") + } +} + +func TestSaveSiteStoresTheLanguage(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + + slugs, err := d.SiteSlugs(ctx) + if err != nil || len(slugs) == 0 { + t.Skipf("SiteSlugs() = %v, %v, skipping without a site", slugs, err) + } + before, err := d.SiteBySlug(ctx, slugs[0]) + if err != nil { + t.Fatalf("SiteBySlug(%q) err = %v, want nil", slugs[0], err) + } + settings, err := d.SiteSettings(ctx, before.ID) + if err != nil { + t.Fatalf("SiteSettings() err = %v, want nil", err) + } + t.Cleanup(func() { + restore := *before + if err := d.SaveSite(context.Background(), &restore, settings, true); err != nil { + t.Errorf("SaveSite(restore) err = %v, want nil", err) + } + }) + + next := *before + next.Language = "en" + if err := d.SaveSite(ctx, &next, settings, true); err != nil { + t.Fatalf("SaveSite() err = %v, want nil", err) + } + after, err := d.SiteBySlug(ctx, slugs[0]) + if err != nil { + t.Fatalf("SiteBySlug(%q) err = %v, want nil", slugs[0], err) + } + if after.Language != "en" { + t.Errorf("SiteBySlug().Language = %q, want %q", after.Language, "en") + } +} diff --git a/internal/db/like_write.go b/internal/db/like_write.go new file mode 100644 index 00000000..a78de8a1 --- /dev/null +++ b/internal/db/like_write.go @@ -0,0 +1,136 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +// One query for the whole page. A thread renders dozens of posts and asking per +// post would make the page cost grow with it. +var qPostLikeCounts = register("PostLikeCounts", ` +SELECT post_id, count(*) +FROM web_forumpostlike +WHERE post_id = ANY($1) +GROUP BY post_id`) + +func (d *DB) PostLikeCounts(ctx context.Context, postIDs []int64) (map[int64]int, error) { + out := map[int64]int{} + if len(postIDs) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qPostLikeCounts, postIDs) + if err != nil { + return nil, fmt.Errorf("count likes: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + var n int + if err := rows.Scan(&id, &n); err != nil { + return nil, fmt.Errorf("scan like count: %w", err) + } + out[id] = n + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("count likes: %w", err) + } + return out, nil +} + +var qPostsLikedBy = register("PostsLikedBy", ` +SELECT post_id +FROM web_forumpostlike +WHERE user_id = $1 AND post_id = ANY($2)`) + +func (d *DB) PostsLikedBy(ctx context.Context, userID int64, postIDs []int64) (map[int64]bool, error) { + out := map[int64]bool{} + if len(postIDs) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qPostsLikedBy, userID, postIDs) + if err != nil { + return nil, fmt.Errorf("read likes of user %d: %w", userID, err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan liked post: %w", err) + } + out[id] = true + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read likes of user %d: %w", userID, err) + } + return out, nil +} + +var qLikePost = register("LikePost", ` +INSERT INTO web_forumpostlike (post_id, user_id, created_at) +VALUES ($1, $2, $3) +ON CONFLICT DO NOTHING`) + +// A false answer means the like was already there, which is how the caller +// knows not to tell the author a second time. +func (d *DB) LikePost(ctx context.Context, postID, userID int64, at time.Time) (bool, error) { + tag, err := d.pool.Exec(ctx, qLikePost, postID, userID, at) + if err != nil { + return false, fmt.Errorf("like post %d: %w", postID, err) + } + return tag.RowsAffected() > 0, nil +} + +var qUnlikePost = register("UnlikePost", ` +DELETE FROM web_forumpostlike WHERE post_id = $1 AND user_id = $2`) + +func (d *DB) UnlikePost(ctx context.Context, postID, userID int64) error { + if _, err := d.pool.Exec(ctx, qUnlikePost, postID, userID); err != nil { + return fmt.Errorf("unlike post %d: %w", postID, err) + } + return nil +} + +var qPostLikers = register("PostLikers", ` +SELECT `+prefixed("u", userColumns)+` +FROM web_forumpostlike l +JOIN web_user u ON u.id = l.user_id +WHERE l.post_id = $1 +ORDER BY l.created_at DESC, l.id DESC +OFFSET $2 LIMIT $3`) + +func (d *DB) PostLikers(ctx context.Context, postID int64, offset, limit int) ([]User, error) { + rows, err := d.pool.Query(ctx, qPostLikers, postID, offset, limit) + if err != nil { + return nil, fmt.Errorf("list likers of post %d: %w", postID, err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + dest, finish := userDest(&u) + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan liker: %w", err) + } + finish() + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list likers of post %d: %w", postID, err) + } + return out, nil +} + +var qPostLikeCount = register("PostLikeCount", ` +SELECT count(*) FROM web_forumpostlike WHERE post_id = $1`) + +func (d *DB) PostLikeCount(ctx context.Context, postID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qPostLikeCount, postID).Scan(&n); err != nil { + return 0, fmt.Errorf("count likes of post %d: %w", postID, err) + } + return n, nil +} diff --git a/internal/db/like_write_test.go b/internal/db/like_write_test.go new file mode 100644 index 00000000..cb2e4b97 --- /dev/null +++ b/internal/db/like_write_test.go @@ -0,0 +1,204 @@ +package db + +import ( + "context" + "testing" + "time" +) + +func scratchThread(t *testing.T, d *DB) int64 { + t.Helper() + ctx := context.Background() + article := scratchArticle(t, d) + var id int64 + err := d.pool.QueryRow(ctx, ` +INSERT INTO web_forumthread (article_id, name, description, created_at, updated_at, is_pinned, is_locked) +VALUES ($1, 'Probe Like Thread', '', now(), now(), false, false) +RETURNING id`, article).Scan(&id) + if err != nil { + t.Fatalf("insert scratch thread err = %v, want nil", err) + } + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM web_forumthread WHERE id = $1`, id); err != nil { + t.Errorf("clean up scratch thread err = %v, want nil", err) + } + }) + return id +} + +func scratchPost(t *testing.T, d *DB, threadID int64, name string) int64 { + t.Helper() + var id int64 + err := d.pool.QueryRow(context.Background(), ` +INSERT INTO web_forumpost (thread_id, name, created_at, updated_at) +VALUES ($1, $2, now(), now()) +RETURNING id`, threadID, name).Scan(&id) + if err != nil { + t.Fatalf("insert scratch post err = %v, want nil", err) + } + t.Cleanup(func() { + if _, err := d.pool.Exec(context.Background(), + `DELETE FROM web_forumpost WHERE id = $1`, id); err != nil { + t.Errorf("clean up scratch post err = %v, want nil", err) + } + }) + return id +} + +func TestLikePostCountsOnce(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + post := scratchPost(t, d, scratchThread(t, d), "probe") + user := scratchUser(t, d, "probe-like-a") + + for i := 0; i < 2; i++ { + if _, err := d.LikePost(ctx, post, user, time.Now().UTC()); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + } + got, err := d.PostLikeCount(ctx, post) + if err != nil { + t.Fatalf("PostLikeCount() err = %v, want nil", err) + } + if got != 1 { + t.Errorf("PostLikeCount() = %d, want 1", got) + } +} + +func TestUnlikePostTakesTheLikeBack(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + post := scratchPost(t, d, scratchThread(t, d), "probe") + user := scratchUser(t, d, "probe-like-b") + + if _, err := d.LikePost(ctx, post, user, time.Now().UTC()); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + if err := d.UnlikePost(ctx, post, user); err != nil { + t.Fatalf("UnlikePost() err = %v, want nil", err) + } + got, err := d.PostLikeCount(ctx, post) + if err != nil { + t.Fatalf("PostLikeCount() err = %v, want nil", err) + } + if got != 0 { + t.Errorf("PostLikeCount() = %d, want 0", got) + } +} + +func TestUnlikePostThatWasNeverLiked(t *testing.T) { + d := writeTestDB(t) + post := scratchPost(t, d, scratchThread(t, d), "probe") + user := scratchUser(t, d, "probe-like-c") + + if err := d.UnlikePost(context.Background(), post, user); err != nil { + t.Errorf("UnlikePost() err = %v, want nil", err) + } +} + +func TestPostLikeCountsAnswerEveryPostAtOnce(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + thread := scratchThread(t, d) + first := scratchPost(t, d, thread, "probe one") + second := scratchPost(t, d, thread, "probe two") + third := scratchPost(t, d, thread, "probe three") + users := []int64{ + scratchUser(t, d, "probe-like-d"), + scratchUser(t, d, "probe-like-e"), + } + + for _, u := range users { + if _, err := d.LikePost(ctx, first, u, time.Now().UTC()); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + } + if _, err := d.LikePost(ctx, second, users[0], time.Now().UTC()); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + + got, err := d.PostLikeCounts(ctx, []int64{first, second, third}) + if err != nil { + t.Fatalf("PostLikeCounts() err = %v, want nil", err) + } + if got[first] != 2 { + t.Errorf("PostLikeCounts()[first] = %d, want 2", got[first]) + } + if got[second] != 1 { + t.Errorf("PostLikeCounts()[second] = %d, want 1", got[second]) + } + if _, ok := got[third]; ok { + t.Errorf("PostLikeCounts() holds the unliked post, want it absent") + } +} + +func TestPostsLikedByNamesOnlyTheReadersOwn(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + thread := scratchThread(t, d) + mine := scratchPost(t, d, thread, "probe mine") + theirs := scratchPost(t, d, thread, "probe theirs") + me := scratchUser(t, d, "probe-like-f") + other := scratchUser(t, d, "probe-like-g") + + if _, err := d.LikePost(ctx, mine, me, time.Now().UTC()); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + if _, err := d.LikePost(ctx, theirs, other, time.Now().UTC()); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + + got, err := d.PostsLikedBy(ctx, me, []int64{mine, theirs}) + if err != nil { + t.Fatalf("PostsLikedBy() err = %v, want nil", err) + } + if !got[mine] { + t.Errorf("PostsLikedBy()[mine] = false, want true") + } + if got[theirs] { + t.Errorf("PostsLikedBy()[theirs] = true, want false") + } +} + +func TestPostLikersComeBackNewestFirstInAWindow(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + post := scratchPost(t, d, scratchThread(t, d), "probe") + at := time.Now().UTC() + + names := []string{"probe-like-h", "probe-like-i", "probe-like-j"} + ids := make([]int64, 0, len(names)) + for i, name := range names { + id := scratchUser(t, d, name) + ids = append(ids, id) + if _, err := d.LikePost(ctx, post, id, at.Add(time.Duration(i)*time.Minute)); err != nil { + t.Fatalf("LikePost() err = %v, want nil", err) + } + } + + first, err := d.PostLikers(ctx, post, 0, 2) + if err != nil { + t.Fatalf("PostLikers() err = %v, want nil", err) + } + if len(first) != 2 { + t.Fatalf("len(PostLikers(0, 2)) = %d, want 2", len(first)) + } + if first[0].ID != ids[2] { + t.Errorf("PostLikers()[0].ID = %d, want %d", first[0].ID, ids[2]) + } + if first[1].ID != ids[1] { + t.Errorf("PostLikers()[1].ID = %d, want %d", first[1].ID, ids[1]) + } + + second, err := d.PostLikers(ctx, post, 2, 2) + if err != nil { + t.Fatalf("PostLikers() err = %v, want nil", err) + } + if len(second) != 1 { + t.Fatalf("len(PostLikers(2, 2)) = %d, want 1", len(second)) + } + if second[0].ID != ids[0] { + t.Errorf("PostLikers(2, 2)[0].ID = %d, want %d", second[0].ID, ids[0]) + } +} diff --git a/internal/db/links.go b/internal/db/links.go new file mode 100644 index 00000000..734ecb6a --- /dev/null +++ b/internal/db/links.go @@ -0,0 +1,64 @@ +package db + +import ( + "context" + "fmt" +) + +const ( + LinkInclude = "include" + LinkPlain = "link" +) + +type ExternalLink struct { + From string + Type string +} + +var qLinksTo = register("LinksTo", ` +SELECT link_from, link_type +FROM web_externallink +WHERE link_to = $1 AND to_site_id = $2`) + +func (d *DB) LinksTo(ctx context.Context, siteID int64, fullName string) ([]ExternalLink, error) { + rows, err := d.pool.Query(ctx, qLinksTo, fullName, siteID) + if err != nil { + return nil, fmt.Errorf("query links to %q: %w", fullName, err) + } + defer rows.Close() + + var out []ExternalLink + for rows.Next() { + var link ExternalLink + if err := rows.Scan(&link.From, &link.Type); err != nil { + return nil, fmt.Errorf("scan link: %w", err) + } + out = append(out, link) + } + return out, rows.Err() +} + +var qArticleChildren = register("ArticleChildren", ` +SELECT `+articleColumns+` +FROM web_article +WHERE parent_id = $1 +ORDER BY id`) + +func (d *DB) ArticleChildren(ctx context.Context, articleID int64) ([]Article, error) { + rows, err := d.pool.Query(ctx, qArticleChildren, articleID) + if err != nil { + return nil, fmt.Errorf("query children of %d: %w", articleID, err) + } + defer rows.Close() + + var out []Article + for rows.Next() { + var a Article + if err := rows.Scan(&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, &a.Locked, + &a.CreatedAt, &a.UpdatedAt, &a.MediaName); err != nil { + return nil, fmt.Errorf("scan child: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} diff --git a/internal/db/listpages.go b/internal/db/listpages.go new file mode 100644 index 00000000..abb9b1c6 --- /dev/null +++ b/internal/db/listpages.go @@ -0,0 +1,421 @@ +package db + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +// End is the first moment after the period. Range and ExcludeRange read both +// ends, the rest read only the end the operator names. +type TimeFilter struct { + Op string + Start time.Time + End time.Time +} + +const ( + TimeRange = "range" + TimeExcludeRange = "exclude_range" + TimeLT = "lt" + TimeLTE = "lte" + TimeGT = "gt" + TimeGTE = "gte" +) + +type NumFilter struct { + Op string + Value float64 +} + +const ( + NumEQ = "eq" + NumNE = "ne" + NumLT = "lt" + NumLTE = "lte" + NumGT = "gt" + NumGTE = "gte" +) + +const ( + SortCreatedAt = "created_at" + SortCreatedBy = "created_by" + SortName = "name" + SortTitle = "title" + SortUpdatedAt = "updated_at" + SortFullName = "fullname" + SortRating = "rating" + SortVotes = "votes" + SortPopularity = "popularity" + SortRandom = "random" + SortSize = "size" + SortRevisions = "revisions" + SortComments = "comments" +) + +type Sort struct { + Column string + Ascending bool +} + +type ListFilter struct { + SiteID int64 + + Hidden []string + + PageType string + Name string + HasName bool + NamePrefix string + HasNamePrefix bool + + NoTags bool + RequiredTags []int64 + PresentTags []int64 + AbsentTags []int64 + ExactTags []int64 + + NotID *int64 + + Categories []string + NotCategories []string + + HasParent bool + ParentID *int64 + HasNotParent bool + NotParentID *int64 + + AuthorID *int64 + + LinkTo string + HasLinkTo bool + + CreatedAt *TimeFilter + UpdatedAt *TimeFilter + + Rating *NumFilter + Votes *NumFilter + Popularity *NumFilter + + RatingMode string + + Sort Sort +} + +const ( + PageTypeNormal = "normal" + PageTypeHidden = "hidden" +) + +type listBuilder struct { + args []any + where []string +} + +func (b *listBuilder) arg(v any) string { + b.args = append(b.args, v) + return "$" + strconv.Itoa(len(b.args)) +} + +// A site that rates nothing still has to sort by something, so the id stands +// in. +func ratingExpr(mode string) string { + switch mode { + case "updown": + return "COALESCE(v.sum_rate, 0.0)" + case "stars": + return "COALESCE(v.avg_rate, 0.0)" + } + return "a.id" +} + +// The rounding is Postgres' own, which breaks ties away from zero and so does +// not agree with the popularity the page variables compute. +func popularityExpr(mode string) string { + good := "COALESCE(v.good_updown, 0)" + if mode == "stars" { + good = "COALESCE(v.good_stars, 0)" + } + return "CASE WHEN COALESCE(v.num_votes, 0) > 0 THEN ROUND(" + + good + "::float / COALESCE(v.num_votes, 0)::float * 100) ELSE 0 END" +} + +const votesExpr = "COALESCE(v.num_votes, 0)" + +// The three counts are subqueries rather than joins because a join would +// multiply the article row and SELECT DISTINCT would then have to undo it. +const ( + sizeExpr = "COALESCE(length((SELECT av.source FROM web_articleversion av" + + " WHERE av.article_id = a.id ORDER BY av.created_at DESC LIMIT 1)), 0)" + revisionsExpr = "(SELECT COUNT(*) FROM web_articlelogentry le WHERE le.article_id = a.id)" + commentsExpr = "(SELECT COUNT(*) FROM web_forumpost p" + + " JOIN web_forumthread th ON th.id = p.thread_id WHERE th.article_id = a.id)" +) + +const voteJoin = ` +LEFT JOIN ( + SELECT article_id, + COUNT(*) AS num_votes, + COALESCE(SUM(rate), 0.0) AS sum_rate, + COALESCE(AVG(rate), 0.0) AS avg_rate, + COUNT(*) FILTER (WHERE rate > 0) AS good_updown, + COUNT(*) FILTER (WHERE rate >= 3.0) AS good_stars + FROM web_vote + GROUP BY article_id +) v ON v.article_id = a.id` + +const authorJoin = ` +LEFT JOIN ( + SELECT link.article_id, u.username + FROM web_article_authors link + JOIN web_user u ON u.id = link.user_id +) au ON au.article_id = a.id` + +func (f ListFilter) build(b *listBuilder) string { + b.where = append(b.where, "a.site_id = "+b.arg(f.SiteID)) + if len(f.Hidden) > 0 { + b.where = append(b.where, "NOT (a.category = ANY("+b.arg(f.Hidden)+"))") + } + switch f.PageType { + case PageTypeNormal: + b.where = append(b.where, `a.name NOT LIKE '\_%'`) + case PageTypeHidden: + b.where = append(b.where, `a.name LIKE '\_%'`) + } + if f.HasName { + b.where = append(b.where, "a.name = "+b.arg(f.Name)) + } + if f.HasNamePrefix { + b.where = append(b.where, "a.name LIKE "+b.arg(likePrefix(f.NamePrefix))) + } + if f.NoTags { + b.where = append(b.where, "NOT EXISTS (SELECT 1 FROM web_article_tags t WHERE t.article_id = a.id)") + } + f.buildTags(b) + if f.NotID != nil { + b.where = append(b.where, "a.id <> "+b.arg(*f.NotID)) + } + if len(f.Categories) > 0 { + b.where = append(b.where, "a.category = ANY("+b.arg(f.Categories)+")") + } + if len(f.NotCategories) > 0 { + b.where = append(b.where, "NOT (a.category = ANY("+b.arg(f.NotCategories)+"))") + } + if f.HasParent { + if f.ParentID == nil { + b.where = append(b.where, "a.parent_id IS NULL") + } else { + b.where = append(b.where, "a.parent_id = "+b.arg(*f.ParentID)) + } + } + if f.HasNotParent { + if f.NotParentID == nil { + b.where = append(b.where, "a.parent_id IS NOT NULL") + } else { + b.where = append(b.where, "a.parent_id IS DISTINCT FROM "+b.arg(*f.NotParentID)) + } + } + if f.AuthorID != nil { + b.where = append(b.where, "EXISTS (SELECT 1 FROM web_article_authors au"+ + " WHERE au.article_id = a.id AND au.user_id = "+b.arg(*f.AuthorID)+")") + } + if f.HasLinkTo { + b.where = append(b.where, "EXISTS (SELECT 1 FROM web_externallink l"+ + " WHERE l.link_type = 'link' AND l.from_site_id = "+b.arg(f.SiteID)+ + " AND l.to_site_id = "+b.arg(f.SiteID)+ + " AND lower("+completeFrom+") = lower(a.complete_full_name)"+ + " AND lower("+completeTo+") = "+b.arg(dumbName(f.LinkTo))+")") + } + f.buildTime(b, "a.created_at", f.CreatedAt) + f.buildTime(b, "a.updated_at", f.UpdatedAt) + f.buildNumbers(b) + + sql := "FROM web_article a" + voteJoin + // Sorting by author multiplies the row rather than picking one of them, so + // a page with two authors is listed twice. + if f.Sort.Column == SortCreatedBy { + sql += authorJoin + } + if len(b.where) > 0 { + sql += "\nWHERE " + strings.Join(b.where, "\n AND ") + } + return sql +} + +func (f ListFilter) buildTags(b *listBuilder) { + hasAll := func(ids []int64) { + if len(ids) == 0 { + return + } + b.where = append(b.where, "(SELECT COUNT(DISTINCT t.tag_id) FROM web_article_tags t"+ + " WHERE t.article_id = a.id AND t.tag_id = ANY("+b.arg(ids)+")) = "+b.arg(len(ids))) + } + hasAll(f.ExactTags) + if len(f.ExactTags) > 0 { + b.where = append(b.where, "NOT EXISTS (SELECT 1 FROM web_article_tags t"+ + " WHERE t.article_id = a.id AND NOT (t.tag_id = ANY("+b.arg(f.ExactTags)+")))") + } + hasAll(f.RequiredTags) + if len(f.PresentTags) > 0 { + b.where = append(b.where, "EXISTS (SELECT 1 FROM web_article_tags t"+ + " WHERE t.article_id = a.id AND t.tag_id = ANY("+b.arg(f.PresentTags)+"))") + } + if len(f.AbsentTags) > 0 { + b.where = append(b.where, "NOT EXISTS (SELECT 1 FROM web_article_tags t"+ + " WHERE t.article_id = a.id AND t.tag_id = ANY("+b.arg(f.AbsentTags)+"))") + } +} + +func (f ListFilter) buildTime(b *listBuilder, column string, c *TimeFilter) { + if c == nil { + return + } + switch c.Op { + case TimeRange: + b.where = append(b.where, column+" >= "+b.arg(c.Start)+" AND "+column+" < "+b.arg(c.End)) + case TimeExcludeRange: + b.where = append(b.where, "("+column+" < "+b.arg(c.Start)+" OR "+column+" >= "+b.arg(c.End)+")") + case TimeLT: + b.where = append(b.where, column+" < "+b.arg(c.Start)) + case TimeLTE: + b.where = append(b.where, column+" < "+b.arg(c.End)) + case TimeGT: + b.where = append(b.where, column+" >= "+b.arg(c.End)) + case TimeGTE: + b.where = append(b.where, column+" >= "+b.arg(c.Start)) + } +} + +func (f ListFilter) buildNumbers(b *listBuilder) { + add := func(expr string, n *NumFilter) { + if n == nil { + return + } + arg := b.arg(n.Value) + switch n.Op { + case NumEQ: + b.where = append(b.where, expr+" = "+arg) + case NumNE: + b.where = append(b.where, "NOT ("+expr+" = "+arg+")") + case NumLT: + b.where = append(b.where, expr+" < "+arg) + case NumLTE: + b.where = append(b.where, expr+" <= "+arg) + case NumGT: + b.where = append(b.where, expr+" > "+arg) + case NumGTE: + b.where = append(b.where, expr+" >= "+arg) + } + } + add(ratingExpr(f.RatingMode), f.Rating) + add(votesExpr, f.Votes) + add(popularityExpr(f.RatingMode), f.Popularity) +} + +func likePrefix(prefix string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return r.Replace(prefix) + "%" +} + +// orderBy puts the sort column in the select list too, which SELECT DISTINCT +// requires of anything it orders by. +func (f ListFilter) orderBy() (expr, extra string) { + direction := " ASC" + if !f.Sort.Ascending { + direction = " DESC" + } + switch f.Sort.Column { + case SortCreatedAt: + return "a.created_at" + direction, "" + case SortCreatedBy: + return "author_name" + direction, ", au.username AS author_name" + case SortName: + return "a.name" + direction, "" + case SortTitle: + return "a.title" + direction, "" + case SortUpdatedAt: + return "a.updated_at" + direction, "" + case SortFullName: + return "full_name" + direction, ", a.complete_full_name AS full_name" + case SortRating: + return "rating" + direction, ", " + ratingExpr(f.RatingMode) + " AS rating" + case SortVotes: + return "num_votes" + direction, ", " + votesExpr + " AS num_votes" + case SortPopularity: + return "popularity" + direction, ", " + popularityExpr(f.RatingMode) + " AS popularity" + case SortRandom: + return "shuffled", ", RANDOM() AS shuffled" + case SortSize: + return "size" + direction, ", " + sizeExpr + " AS size" + case SortRevisions: + return "revisions" + direction, ", " + revisionsExpr + " AS revisions" + case SortComments: + return "comments" + direction, ", " + commentsExpr + " AS comments" + } + // A column nobody recognises lists the newest first whatever direction was + // asked for. + return "a.created_at DESC", "" +} + +func (f ListFilter) selectSQL(b *listBuilder, offset int, limit *int) string { + body := f.build(b) + order, extra := f.orderBy() + sql := "SELECT DISTINCT " + prefixedArticleColumns + extra + "\n" + body + "\nORDER BY " + order + if limit != nil { + sql += "\nLIMIT " + b.arg(*limit) + } + if offset > 0 { + sql += "\nOFFSET " + b.arg(offset) + } + return sql +} + +// Exposed so the schema-drift test can send a built statement to Postgres the +// way it sends the ones written out by hand. +func (f ListFilter) SelectSQL(offset int, limit *int) (string, []any) { + b := &listBuilder{} + return f.selectSQL(b, offset, limit), b.args +} + +func (d *DB) ListArticles(ctx context.Context, f ListFilter, offset int, limit *int) ([]Article, error) { + b := &listBuilder{} + sql := f.selectSQL(b, offset, limit) + + rows, err := d.pool.Query(ctx, sql, b.args...) + if err != nil { + return nil, fmt.Errorf("query listed articles: %w", err) + } + defer rows.Close() + + var out []Article + for rows.Next() { + var a Article + dest := []any{&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, + &a.Locked, &a.CreatedAt, &a.UpdatedAt, &a.MediaName} + for i := len(dest); i < len(rows.FieldDescriptions()); i++ { + dest = append(dest, new(any)) + } + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan listed article: %w", err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read listed articles: %w", err) + } + return out, nil +} + +// Counted before pagination narrows it, so offset and limit still apply. +func (d *DB) CountArticles(ctx context.Context, f ListFilter, offset int, limit *int) (int, error) { + b := &listBuilder{} + inner := f.selectSQL(b, offset, limit) + + var n int + if err := d.pool.QueryRow(ctx, "SELECT COUNT(*) FROM ("+inner+") sub", b.args...).Scan(&n); err != nil { + return 0, fmt.Errorf("count listed articles: %w", err) + } + return n, nil +} diff --git a/internal/db/listpages_test.go b/internal/db/listpages_test.go new file mode 100644 index 00000000..612b6202 --- /dev/null +++ b/internal/db/listpages_test.go @@ -0,0 +1,120 @@ +package db + +import ( + "context" + "strconv" + "strings" + "testing" + "time" +) + +func int64s(v ...int64) []int64 { return v } + +// listFilterVariants covers every branch that puts SQL together, since a +// generated statement escapes the check the registered ones go through. +func listFilterVariants() map[string]ListFilter { + parent := int64(3) + return map[string]ListFilter{ + "empty": {}, + "hidden": {Hidden: []string{"admin"}}, + "normal-pages": {PageType: PageTypeNormal}, + "hidden-pages": {PageType: PageTypeHidden}, + "name": {Name: "main", HasName: true}, + "name-prefix": {NamePrefix: "scp-", HasNamePrefix: true}, + "no-tags": {NoTags: true}, + "exact-tags": {ExactTags: int64s(1, 2)}, + "not-id": {NotID: &parent}, + "required-tags": {RequiredTags: int64s(1, 2)}, + "present-tags": {PresentTags: int64s(3)}, + "absent-tags": {AbsentTags: int64s(4)}, + "categories": {Categories: []string{"scp"}, NotCategories: []string{"meta"}}, + "no-parent": {HasParent: true}, + "parent": {HasParent: true, ParentID: &parent}, + "not-parent": {HasNotParent: true, NotParentID: &parent}, + "any-parent": {HasNotParent: true}, + "author": {AuthorID: &parent}, + "created-range": {CreatedAt: &TimeFilter{Op: TimeRange, Start: time.Unix(0, 0), End: time.Unix(1, 0)}}, + "created-out": {CreatedAt: &TimeFilter{Op: TimeExcludeRange, Start: time.Unix(0, 0), End: time.Unix(1, 0)}}, + "created-lt": {CreatedAt: &TimeFilter{Op: TimeLT, Start: time.Unix(0, 0)}}, + "created-lte": {CreatedAt: &TimeFilter{Op: TimeLTE, Start: time.Unix(0, 0)}}, + "created-gt": {CreatedAt: &TimeFilter{Op: TimeGT, End: time.Unix(0, 0)}}, + "created-gte": {CreatedAt: &TimeFilter{Op: TimeGTE, End: time.Unix(0, 0)}}, + "updated-range": {UpdatedAt: &TimeFilter{Op: TimeRange, Start: time.Unix(0, 0), End: time.Unix(1, 0)}}, + "updated-lt": {UpdatedAt: &TimeFilter{Op: TimeLT, Start: time.Unix(0, 0)}}, + "link-to": {LinkTo: "theme:black", HasLinkTo: true}, + "link-to-bare": {LinkTo: "start", HasLinkTo: true}, + "rating-updown": {RatingMode: "updown", Rating: &NumFilter{Op: NumGTE, Value: 3}}, + "rating-stars": {RatingMode: "stars", Rating: &NumFilter{Op: NumLT, Value: 3}}, + "rating-off": {RatingMode: "disabled", Rating: &NumFilter{Op: NumNE, Value: 0}}, + "votes": {Votes: &NumFilter{Op: NumEQ, Value: 2}}, + "popularity": {RatingMode: "updown", Popularity: &NumFilter{Op: NumGT, Value: 50}}, + "sort-created": {Sort: Sort{Column: SortCreatedAt}}, + "sort-author": {Sort: Sort{Column: SortCreatedBy, Ascending: true}}, + "sort-name": {Sort: Sort{Column: SortName, Ascending: true}}, + "sort-title": {Sort: Sort{Column: SortTitle}}, + "sort-updated": {Sort: Sort{Column: SortUpdatedAt}}, + "sort-fullname": {Sort: Sort{Column: SortFullName}}, + "sort-rating": {RatingMode: "stars", Sort: Sort{Column: SortRating}}, + "sort-votes": {Sort: Sort{Column: SortVotes}}, + "sort-pop": {RatingMode: "updown", Sort: Sort{Column: SortPopularity}}, + "sort-random": {Sort: Sort{Column: SortRandom}}, + "sort-size": {Sort: Sort{Column: SortSize}}, + "sort-revisions": {Sort: Sort{Column: SortRevisions}}, + "sort-comments": {Sort: Sort{Column: SortComments}}, + } +} + +// TestListFilterSQLMatchesSchema sends every shape the builder can produce to +// Postgres. The registered statements get this from TestQueriesMatchSchema, and +// without it the built ones would only fail on a page load. +func TestListFilterSQLMatchesSchema(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + limit := 20 + + for name, filter := range listFilterVariants() { + for _, window := range []struct { + label string + offset int + limit *int + }{{"plain", 0, nil}, {"windowed", 5, &limit}} { + if _, err := d.ListArticles(ctx, filter, window.offset, window.limit); err != nil { + t.Errorf("ListArticles(%s, %s) err = %v, want nil", name, window.label, err) + } + if _, err := d.CountArticles(ctx, filter, window.offset, window.limit); err != nil { + t.Errorf("CountArticles(%s, %s) err = %v, want nil", name, window.label, err) + } + } + } +} + +func TestLikePrefixEscapesWildcards(t *testing.T) { + cases := map[string]string{ + "scp-": "scp-%", + "a_b": `a\_b%`, + "a%b": `a\%b%`, + `a\b`: `a\\b%`, + "": "%", + } + for in, want := range cases { + if got := likePrefix(in); got != want { + t.Errorf("likePrefix(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSelectSQLNamesEveryArgument(t *testing.T) { + for name, filter := range listFilterVariants() { + limit := 10 + sql, args := filter.SelectSQL(3, &limit) + for i := range args { + if !strings.Contains(sql, placeholder(i+1)) { + t.Errorf("SelectSQL(%s) leaves %s unused", name, placeholder(i+1)) + } + } + } +} + +func placeholder(n int) string { + return "$" + strconv.Itoa(n) +} diff --git a/internal/db/listpages_write_test.go b/internal/db/listpages_write_test.go new file mode 100644 index 00000000..f6779118 --- /dev/null +++ b/internal/db/listpages_write_test.go @@ -0,0 +1,141 @@ +package db + +import ( + "context" + "slices" + "testing" + "time" +) + +type listedProbe struct { + site int64 + ids map[string]int64 +} + +func listedPages(t *testing.T, d *DB) listedProbe { + t.Helper() + ctx := context.Background() + site := scratchSite(t, d) + t.Cleanup(func() { + clean := context.Background() + for _, sql := range []string{ + `DELETE FROM web_article_tags WHERE article_id IN (SELECT id FROM web_article WHERE site_id = $1)`, + `DELETE FROM web_tag WHERE category_id IN (SELECT id FROM web_tagscategory WHERE site_id = $1)`, + `DELETE FROM web_tagscategory WHERE site_id = $1`, + `DELETE FROM web_articlelogentry WHERE article_id IN (SELECT id FROM web_article WHERE site_id = $1)`, + `DELETE FROM web_articleversion WHERE article_id IN (SELECT id FROM web_article WHERE site_id = $1)`, + `DELETE FROM web_article_authors WHERE article_id IN (SELECT id FROM web_article WHERE site_id = $1)`, + `DELETE FROM web_article WHERE site_id = $1`, + } { + if _, err := d.pool.Exec(clean, sql, site); err != nil { + t.Errorf("clean up pages of site %d err = %v, want nil", site, err) + } + } + }) + pages := []struct { + name string + created time.Time + tags []string + }{ + {"alpha", time.Date(2021, 2, 9, 15, 0, 0, 0, time.UTC), []string{"x", "y"}}, + {"beta", time.Date(2021, 2, 10, 0, 0, 0, 0, time.UTC), []string{"x", "y", "z"}}, + {"gamma", time.Date(2021, 12, 31, 23, 30, 0, 0, time.UTC), []string{"x"}}, + } + probe := listedProbe{site: site, ids: map[string]int64{}} + for _, p := range pages { + id, err := d.CreateArticle(ctx, site, "_default", p.name, p.name, nil, p.created) + if err != nil { + t.Fatalf("CreateArticle(%s) err = %v, want nil", p.name, err) + } + if _, _, err := d.SetArticleTags(ctx, site, id, p.tags, true, nil, p.created); err != nil { + t.Fatalf("SetArticleTags(%s) err = %v, want nil", p.name, err) + } + if _, err := d.pool.Exec(ctx, `UPDATE web_article SET created_at = $2 WHERE id = $1`, id, p.created); err != nil { + t.Fatal(err) + } + probe.ids[p.name] = id + } + return probe +} + +func (p listedProbe) names(t *testing.T, d *DB, f ListFilter) []string { + t.Helper() + f.SiteID = p.site + listed, err := d.ListArticles(context.Background(), f, 0, nil) + if err != nil { + t.Fatalf("ListArticles() err = %v, want nil", err) + } + var out []string + for _, a := range listed { + out = append(out, a.Name) + } + return out +} + +func TestListArticlesExactTags(t *testing.T) { + d := writeTestDB(t) + p := listedPages(t, d) + var tags []int64 + if err := d.pool.QueryRow(context.Background(), ` +SELECT array_agg(tag_id ORDER BY tag_id) FROM web_article_tags WHERE article_id = $1`, p.ids["alpha"]).Scan(&tags); err != nil { + t.Fatal(err) + } + + got := p.names(t, d, ListFilter{ExactTags: tags}) + if !slices.Equal(got, []string{"alpha"}) { + t.Errorf("ListArticles(ExactTags of alpha) = %v, want [alpha]", got) + } + got = p.names(t, d, ListFilter{RequiredTags: tags, Sort: Sort{Column: SortName, Ascending: true}}) + if !slices.Equal(got, []string{"alpha", "beta"}) { + t.Errorf("ListArticles(RequiredTags of alpha) = %v, want [alpha beta]", got) + } +} + +func TestListArticlesNotID(t *testing.T) { + d := writeTestDB(t) + p := listedPages(t, d) + got := p.names(t, d, ListFilter{NotID: new(p.ids["beta"]), Sort: Sort{Column: SortName, Ascending: true}}) + if !slices.Equal(got, []string{"alpha", "gamma"}) { + t.Errorf("ListArticles(NotID beta) = %v, want [alpha gamma]", got) + } +} + +func TestListArticlesSortsByCreatedAtBothWays(t *testing.T) { + d := writeTestDB(t) + p := listedPages(t, d) + got := p.names(t, d, ListFilter{Sort: Sort{Column: SortCreatedAt, Ascending: true}}) + if !slices.Equal(got, []string{"alpha", "beta", "gamma"}) { + t.Errorf("ListArticles(created_at asc) = %v, want [alpha beta gamma]", got) + } + got = p.names(t, d, ListFilter{Sort: Sort{Column: SortCreatedAt}}) + if !slices.Equal(got, []string{"gamma", "beta", "alpha"}) { + t.Errorf("ListArticles(created_at desc) = %v, want [gamma beta alpha]", got) + } +} + +func TestListArticlesTimeFilterCoversTheWholePeriod(t *testing.T) { + d := writeTestDB(t) + p := listedPages(t, d) + day := time.Date(2021, 2, 9, 0, 0, 0, 0, time.UTC) + year := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { + name string + f TimeFilter + want []string + }{ + {"range 2021-02-09", TimeFilter{Op: TimeRange, Start: day, End: day.AddDate(0, 0, 1)}, []string{"alpha"}}, + {"range 2021", TimeFilter{Op: TimeRange, Start: year, End: year.AddDate(1, 0, 0)}, []string{"alpha", "beta", "gamma"}}, + {"exclude 2021-02-09", TimeFilter{Op: TimeExcludeRange, Start: day, End: day.AddDate(0, 0, 1)}, []string{"beta", "gamma"}}, + {"lt 2021-02-09", TimeFilter{Op: TimeLT, Start: day, End: day.AddDate(0, 0, 1)}, nil}, + {"lte 2021-02-09", TimeFilter{Op: TimeLTE, Start: day, End: day.AddDate(0, 0, 1)}, []string{"alpha"}}, + {"gt 2021-02-09", TimeFilter{Op: TimeGT, Start: day, End: day.AddDate(0, 0, 1)}, []string{"beta", "gamma"}}, + {"gte 2021-02-09", TimeFilter{Op: TimeGTE, Start: day, End: day.AddDate(0, 0, 1)}, []string{"alpha", "beta", "gamma"}}, + } + for _, c := range cases { + f := c.f + got := p.names(t, d, ListFilter{CreatedAt: &f, Sort: Sort{Column: SortName, Ascending: true}}) + if !slices.Equal(got, c.want) { + t.Errorf("ListArticles(%s) = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/db/member.go b/internal/db/member.go new file mode 100644 index 00000000..a53af94a --- /dev/null +++ b/internal/db/member.go @@ -0,0 +1,90 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/roles" + "github.com/jackc/pgx/v5" +) + +type Member struct { + User + JoinedAt time.Time +} + +// A null role means every account, so one statement covers both the filtered +// and the unfiltered listing rather than two that can drift apart. +const memberFilter = ` +WHERE $1::bigint IS NULL + OR EXISTS (SELECT 1 FROM web_user_roles ur WHERE ur.user_id = u.id AND ur.role_id = $1)` + +var qMembers = register("Members", ` +SELECT `+prefixed("u", userColumns)+`, u.date_joined +FROM web_user u`+memberFilter+` +ORDER BY u.id +OFFSET $2 +LIMIT $3`) + +var qMemberCount = register("MemberCount", ` +SELECT count(*) +FROM web_user u`+memberFilter) + +func (d *DB) Members(ctx context.Context, roleID *int64, offset, limit int) ([]Member, error) { + rows, err := d.pool.Query(ctx, qMembers, roleID, offset, limit) + if err != nil { + return nil, fmt.Errorf("list members: %w", err) + } + defer rows.Close() + + var out []Member + for rows.Next() { + var m Member + dest, finish := userDest(&m.User) + if err := rows.Scan(append(dest, &m.JoinedAt)...); err != nil { + return nil, fmt.Errorf("scan member: %w", err) + } + finish() + out = append(out, m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list members: %w", err) + } + return out, nil +} + +func (d *DB) MemberCount(ctx context.Context, roleID *int64) (int, error) { + var total int + if err := d.pool.QueryRow(ctx, qMemberCount, roleID).Scan(&total); err != nil { + return 0, fmt.Errorf("count members: %w", err) + } + return total, nil +} + +var qRoleByRef = register("RoleByRef", ` +SELECT r.id, r.slug, r.name, r.short_name, r.category_id, r.index, + r.is_staff, r.group_votes, r.inline_visual_mode, r.profile_visual_mode, + r.color, r.icon, r.badge_text, r.badge_bg, r.badge_text_color, r.badge_show_border +FROM web_role r +WHERE r.site_id = $1 AND (lower(r.slug) = lower($2) OR r.id::text = $2) +ORDER BY r.index, r.id +LIMIT 1`) + +func (d *DB) RoleByRef(ctx context.Context, siteID int64, ref string) (*roles.Role, error) { + var role roles.Role + err := d.pool.QueryRow(ctx, qRoleByRef, siteID, ref).Scan( + &role.ID, &role.Slug, &role.Name, &role.ShortName, &role.CategoryID, &role.Index, + &role.IsStaff, &role.GroupVotes, &role.InlineVisualMode, &role.ProfileVisualMode, + &role.Color, &role.Icon, &role.BadgeText, &role.BadgeBg, &role.BadgeTextColor, + &role.BadgeShowBorder, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup role %q: %w", ref, err) + } + return &role, nil +} diff --git a/internal/db/member_sanction.go b/internal/db/member_sanction.go new file mode 100644 index 00000000..4b184677 --- /dev/null +++ b/internal/db/member_sanction.go @@ -0,0 +1,88 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +type MemberSanction struct { + Kind string + Until *time.Time + Reason string + SetBy *int64 + SetAt time.Time +} + +var qActiveSanctions = register("ActiveSanctions", ` +SELECT kind +FROM pwikit_member_sanction +WHERE site_id = $1 AND user_id = $2 AND (until IS NULL OR until > $3)`) + +func (d *DB) ActiveSanctions(ctx context.Context, siteID, userID int64, now time.Time) ([]string, error) { + rows, err := d.pool.Query(ctx, qActiveSanctions, siteID, userID, now) + if err != nil { + return nil, fmt.Errorf("read the sanctions of user %d: %w", userID, err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var kind string + if err := rows.Scan(&kind); err != nil { + return nil, err + } + out = append(out, kind) + } + return out, rows.Err() +} + +var qMemberSanctions = register("MemberSanctions", ` +SELECT kind, until, reason, set_by_id, set_at +FROM pwikit_member_sanction +WHERE site_id = $1 AND user_id = $2 +ORDER BY kind`) + +func (d *DB) MemberSanctions(ctx context.Context, siteID, userID int64) ([]MemberSanction, error) { + rows, err := d.pool.Query(ctx, qMemberSanctions, siteID, userID) + if err != nil { + return nil, fmt.Errorf("read the sanctions of user %d: %w", userID, err) + } + defer rows.Close() + + var out []MemberSanction + for rows.Next() { + var one MemberSanction + if err := rows.Scan(&one.Kind, &one.Until, &one.Reason, &one.SetBy, &one.SetAt); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} + +var qSetSanction = register("SetSanction", ` +INSERT INTO pwikit_member_sanction (site_id, user_id, kind, until, reason, set_by_id, set_at) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (site_id, user_id, kind) DO UPDATE +SET until = EXCLUDED.until, reason = EXCLUDED.reason, + set_by_id = EXCLUDED.set_by_id, set_at = EXCLUDED.set_at`) + +func (d *DB) SetSanction(ctx context.Context, siteID, userID int64, kind string, until *time.Time, + reason string, by *int64, at time.Time) error { + + if _, err := d.pool.Exec(ctx, qSetSanction, siteID, userID, kind, until, reason, by, at); err != nil { + return fmt.Errorf("record the %s of user %d: %w", kind, userID, err) + } + return nil +} + +var qClearSanction = register("ClearSanction", ` +DELETE FROM pwikit_member_sanction WHERE site_id = $1 AND user_id = $2 AND kind = $3`) + +func (d *DB) ClearSanction(ctx context.Context, siteID, userID int64, kind string) error { + if _, err := d.pool.Exec(ctx, qClearSanction, siteID, userID, kind); err != nil { + return fmt.Errorf("lift the %s of user %d: %w", kind, userID, err) + } + return nil +} diff --git a/internal/db/member_sanction_write_test.go b/internal/db/member_sanction_write_test.go new file mode 100644 index 00000000..745b487f --- /dev/null +++ b/internal/db/member_sanction_write_test.go @@ -0,0 +1,90 @@ +package db + +import ( + "context" + "slices" + "testing" + "time" +) + +func TestSanctionsOnlyCountWhileTheyLast(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + site := scratchSite(t, d) + now := time.Now().UTC() + + userID, err := d.CreateUser(ctx, scratchName(t), "Probe Sanctioned", "!", true, now) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, userID) + + forever := now.Add(time.Hour) + if err := d.SetSanction(ctx, site, userID, "ban", nil, "spam", &userID, now); err != nil { + t.Fatalf("SetSanction(ban) err = %v, want nil", err) + } + if err := d.SetSanction(ctx, site, userID, "mute", &forever, "noise", &userID, now); err != nil { + t.Fatalf("SetSanction(mute) err = %v, want nil", err) + } + gone := now.Add(-time.Hour) + if err := d.SetSanction(ctx, site, userID, "rating", &gone, "over", &userID, now); err != nil { + t.Fatalf("SetSanction(rating) err = %v, want nil", err) + } + + kinds, err := d.ActiveSanctions(ctx, site, userID, now) + if err != nil { + t.Fatalf("ActiveSanctions() err = %v, want nil", err) + } + slices.Sort(kinds) + if !slices.Equal(kinds, []string{"ban", "mute"}) { + t.Errorf("ActiveSanctions() = %v, want [ban mute]", kinds) + } + + stored, err := d.MemberSanctions(ctx, site, userID) + if err != nil { + t.Fatalf("MemberSanctions() err = %v, want nil", err) + } + if len(stored) != 3 { + t.Errorf("len(MemberSanctions()) = %d, want 3", len(stored)) + } + for _, one := range stored { + if one.Kind == "ban" && one.Reason != "spam" { + t.Errorf("MemberSanctions()[ban].Reason = %q, want %q", one.Reason, "spam") + } + } + + if err := d.ClearSanction(ctx, site, userID, "ban"); err != nil { + t.Fatalf("ClearSanction() err = %v, want nil", err) + } + kinds, err = d.ActiveSanctions(ctx, site, userID, now) + if err != nil { + t.Fatalf("ActiveSanctions() err = %v, want nil", err) + } + if !slices.Equal(kinds, []string{"mute"}) { + t.Errorf("ActiveSanctions() after lifting the ban = %v, want [mute]", kinds) + } +} + +func TestSanctionsAreKeptPerSite(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + here, there := scratchSite(t, d), scratchSite(t, d) + now := time.Now().UTC() + + userID, err := d.CreateUser(ctx, scratchName(t), "Probe Elsewhere", "!", true, now) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, userID) + + if err := d.SetSanction(ctx, here, userID, "ban", nil, "", nil, now); err != nil { + t.Fatalf("SetSanction() err = %v, want nil", err) + } + kinds, err := d.ActiveSanctions(ctx, there, userID, now) + if err != nil { + t.Fatalf("ActiveSanctions() err = %v, want nil", err) + } + if len(kinds) != 0 { + t.Errorf("ActiveSanctions(other site) = %v, want none", kinds) + } +} diff --git a/internal/db/member_test.go b/internal/db/member_test.go new file mode 100644 index 00000000..6832cc2d --- /dev/null +++ b/internal/db/member_test.go @@ -0,0 +1,117 @@ +package db + +import ( + "context" + "errors" + "strconv" + "testing" +) + +func TestMembersAreOrderedByID(t *testing.T) { + d := newTestDB(t) + + got, err := d.Members(context.Background(), nil, 0, 100) + if err != nil { + t.Fatalf("Members() err = %v, want nil", err) + } + if len(got) == 0 { + t.Fatal("Members() = 0 rows, want at least one") + } + for i := 1; i < len(got); i++ { + if got[i-1].ID >= got[i].ID { + t.Errorf("Members()[%d].ID = %d, want it after %d", i, got[i].ID, got[i-1].ID) + } + } + if got[0].JoinedAt.IsZero() { + t.Errorf("Members()[0].JoinedAt = %v, want a real time", got[0].JoinedAt) + } +} + +func TestMemberCountMatchesTheUnfilteredListing(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + total, err := d.MemberCount(ctx, nil) + if err != nil { + t.Fatalf("MemberCount() err = %v, want nil", err) + } + listed, err := d.Members(ctx, nil, 0, total+1) + if err != nil { + t.Fatalf("Members() err = %v, want nil", err) + } + if len(listed) != total { + t.Errorf("MemberCount() = %d, want %d", total, len(listed)) + } +} + +func TestMembersOffsetSkipsRows(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + all, err := d.Members(ctx, nil, 0, 3) + if err != nil { + t.Fatalf("Members() err = %v, want nil", err) + } + if len(all) < 2 { + t.Skip("the database holds fewer than two users") + } + got, err := d.Members(ctx, nil, 1, 1) + if err != nil { + t.Fatalf("Members() err = %v, want nil", err) + } + if len(got) != 1 { + t.Fatalf("Members(offset 1, limit 1) = %d rows, want 1", len(got)) + } + if got[0].ID != all[1].ID { + t.Errorf("Members(offset 1).ID = %d, want %d", got[0].ID, all[1].ID) + } +} + +func TestMembersFilteredByRole(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + role, err := d.RoleByRef(ctx, seedSiteID(t, d), "everyone") + if err != nil { + t.Fatalf("RoleByRef(\"everyone\") err = %v, want nil", err) + } + filtered, err := d.MemberCount(ctx, &role.ID) + if err != nil { + t.Fatalf("MemberCount() err = %v, want nil", err) + } + total, err := d.MemberCount(ctx, nil) + if err != nil { + t.Fatalf("MemberCount() err = %v, want nil", err) + } + if filtered > total { + t.Errorf("MemberCount(role) = %d, want at most %d", filtered, total) + } +} + +func TestRoleByRefMatchesTheNumber(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + bySlug, err := d.RoleByRef(ctx, seedSiteID(t, d), "everyone") + if err != nil { + t.Fatalf("RoleByRef(\"everyone\") err = %v, want nil", err) + } + byID, err := d.RoleByRef(ctx, seedSiteID(t, d), itoa(bySlug.ID)) + if err != nil { + t.Fatalf("RoleByRef(%q) err = %v, want nil", itoa(bySlug.ID), err) + } + if byID.Slug != bySlug.Slug { + t.Errorf("RoleByRef(%q).Slug = %q, want %q", itoa(bySlug.ID), byID.Slug, bySlug.Slug) + } +} + +func TestRoleByRefUnknown(t *testing.T) { + d := newTestDB(t) + + _, err := d.RoleByRef(context.Background(), seedSiteID(t, d), "no-such-role") + if !errors.Is(err, ErrNotFound) { + t.Errorf("RoleByRef(\"no-such-role\") err = %v, want ErrNotFound", err) + } +} + +func itoa(n int64) string { return strconv.FormatInt(n, 10) } diff --git a/internal/db/message.go b/internal/db/message.go new file mode 100644 index 00000000..ca430302 --- /dev/null +++ b/internal/db/message.go @@ -0,0 +1,245 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type DirectMessage struct { + ID int64 + SenderID int64 + RecipientID int64 + Body string + CreatedAt time.Time + IsRead bool +} + +type Conversation struct { + PartnerID int64 + Last DirectMessage + Unread int +} + +var qConversations = register("Conversations", ` +WITH mine AS ( + SELECT id, + CASE WHEN sender_id = $1 THEN recipient_id ELSE sender_id END AS partner_id + FROM web_directmessage + WHERE sender_id = $1 OR recipient_id = $1 +), newest AS ( + SELECT partner_id, max(id) AS last_id + FROM mine + GROUP BY partner_id +) +SELECT n.partner_id, m.id, m.sender_id, m.recipient_id, m.body, m.created_at, m.is_read, + (SELECT count(*) FROM web_directmessage u + WHERE u.sender_id = n.partner_id AND u.recipient_id = $1 AND NOT u.is_read) +FROM newest n +JOIN web_directmessage m ON m.id = n.last_id +ORDER BY m.created_at DESC`) + +func (d *DB) Conversations(ctx context.Context, userID int64) ([]Conversation, error) { + rows, err := d.pool.Query(ctx, qConversations, userID) + if err != nil { + return nil, fmt.Errorf("list conversations of %d: %w", userID, err) + } + defer rows.Close() + + var out []Conversation + for rows.Next() { + var one Conversation + m := &one.Last + if err := rows.Scan(&one.PartnerID, &m.ID, &m.SenderID, &m.RecipientID, + &m.Body, &m.CreatedAt, &m.IsRead, &one.Unread); err != nil { + return nil, fmt.Errorf("scan conversation: %w", err) + } + out = append(out, one) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list conversations of %d: %w", userID, err) + } + return out, nil +} + +var qConversationBefore = register("ConversationBefore", ` +SELECT id, sender_id, recipient_id, body, created_at, is_read +FROM web_directmessage +WHERE ((sender_id = $1 AND recipient_id = $2) OR (sender_id = $2 AND recipient_id = $1)) + AND ($3::bigint IS NULL OR id < $3) +ORDER BY id DESC +LIMIT $4`) + +func (d *DB) ConversationBefore(ctx context.Context, userID, partnerID int64, before *int64, limit int) ([]DirectMessage, error) { + return d.messages(ctx, qConversationBefore, userID, partnerID, before, limit) +} + +var qConversationAfter = register("ConversationAfter", ` +SELECT id, sender_id, recipient_id, body, created_at, is_read +FROM web_directmessage +WHERE ((sender_id = $1 AND recipient_id = $2) OR (sender_id = $2 AND recipient_id = $1)) + AND id > $3 +ORDER BY id +LIMIT $4`) + +func (d *DB) ConversationAfter(ctx context.Context, userID, partnerID, after int64, limit int) ([]DirectMessage, error) { + return d.messages(ctx, qConversationAfter, userID, partnerID, after, limit) +} + +func (d *DB) messages(ctx context.Context, sql string, userID, partnerID int64, bound any, limit int) ([]DirectMessage, error) { + rows, err := d.pool.Query(ctx, sql, userID, partnerID, bound, limit) + if err != nil { + return nil, fmt.Errorf("list messages between %d and %d: %w", userID, partnerID, err) + } + defer rows.Close() + return scanMessages(rows, userID, partnerID) +} + +var qMessagesBetween = register("MessagesBetween", ` +SELECT id, sender_id, recipient_id, body, created_at, is_read +FROM web_directmessage +WHERE (sender_id = $1 AND recipient_id = $2) OR (sender_id = $2 AND recipient_id = $1) +ORDER BY created_at`) + +func (d *DB) MessagesBetween(ctx context.Context, userID, partnerID int64) ([]DirectMessage, error) { + rows, err := d.pool.Query(ctx, qMessagesBetween, userID, partnerID) + if err != nil { + return nil, fmt.Errorf("list messages between %d and %d: %w", userID, partnerID, err) + } + defer rows.Close() + return scanMessages(rows, userID, partnerID) +} + +var qMessagesByIDs = register("MessagesByIDs", ` +SELECT id, sender_id, recipient_id, body, created_at, is_read +FROM web_directmessage +WHERE id = ANY($3) + AND ((sender_id = $1 AND recipient_id = $2) OR (sender_id = $2 AND recipient_id = $1)) +ORDER BY created_at`) + +func (d *DB) MessagesByIDs(ctx context.Context, userID, partnerID int64, ids []int64) ([]DirectMessage, error) { + rows, err := d.pool.Query(ctx, qMessagesByIDs, userID, partnerID, ids) + if err != nil { + return nil, fmt.Errorf("list messages by id: %w", err) + } + defer rows.Close() + return scanMessages(rows, userID, partnerID) +} + +func scanMessages(rows pgx.Rows, userID, partnerID int64) ([]DirectMessage, error) { + var out []DirectMessage + for rows.Next() { + var m DirectMessage + if err := rows.Scan(&m.ID, &m.SenderID, &m.RecipientID, &m.Body, &m.CreatedAt, &m.IsRead); err != nil { + return nil, fmt.Errorf("scan message: %w", err) + } + out = append(out, m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list messages between %d and %d: %w", userID, partnerID, err) + } + return out, nil +} + +var qIsBlocked = register("IsBlocked", ` +SELECT EXISTS ( + SELECT 1 FROM web_directmessageblock + WHERE blocker_id = $1 AND blocked_id = $2)`) + +func (d *DB) IsBlocked(ctx context.Context, blockerID, blockedID int64) (bool, error) { + var blocked bool + if err := d.pool.QueryRow(ctx, qIsBlocked, blockerID, blockedID).Scan(&blocked); err != nil { + return false, fmt.Errorf("check block of %d by %d: %w", blockedID, blockerID, err) + } + return blocked, nil +} + +var qUnreadMessages = register("UnreadMessages", ` +SELECT count(*) +FROM web_directmessage +WHERE recipient_id = $1 AND NOT is_read`) + +func (d *DB) UnreadMessages(ctx context.Context, userID int64) (int, error) { + var count int + if err := d.pool.QueryRow(ctx, qUnreadMessages, userID).Scan(&count); err != nil { + return 0, fmt.Errorf("count unread messages of %d: %w", userID, err) + } + return count, nil +} + +type Report struct { + ID int64 + ReporterID *int64 + ReportedID *int64 + Reason string + Messages string + Status string + CreatedAt time.Time +} + +var qReport = register("Report", ` +SELECT id, reporter_id, reported_id, reason, reported_messages, status, created_at +FROM web_userreport +WHERE id = $1 AND site_id = $2`) + +func (d *DB) Report(ctx context.Context, siteID, id int64) (*Report, error) { + var r Report + err := d.pool.QueryRow(ctx, qReport, id, siteID).Scan(&r.ID, &r.ReporterID, &r.ReportedID, + &r.Reason, &r.Messages, &r.Status, &r.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("read report %d: %w", id, err) + } + return &r, nil +} + +var qReportsSince = register("ReportsSince", ` +SELECT count(*) +FROM web_userreport +WHERE reporter_id = $1 AND reported_id = $2 AND created_at >= $3 AND site_id = $4`) + +func (d *DB) ReportsSince(ctx context.Context, siteID, reporterID, reportedID int64, since time.Time) (int, error) { + var count int + if err := d.pool.QueryRow(ctx, qReportsSince, reporterID, reportedID, since, siteID).Scan(&count); err != nil { + return 0, fmt.Errorf("count reports by %d against %d: %w", reporterID, reportedID, err) + } + return count, nil +} + +type SuspiciousUser struct { + UserID int64 + Username string + IP *string +} + +var qSuspiciousUsers = register("SuspiciousUsers", ` +SELECT a.user_id, u.username, host(a.address) +FROM pwikit_user_address a +JOIN web_user u ON u.id = a.user_id +ORDER BY a.user_id, a.address`) + +func (d *DB) SuspiciousUsers(ctx context.Context) ([]SuspiciousUser, error) { + rows, err := d.pool.Query(ctx, qSuspiciousUsers) + if err != nil { + return nil, fmt.Errorf("list addresses per user: %w", err) + } + defer rows.Close() + + var out []SuspiciousUser + for rows.Next() { + var one SuspiciousUser + if err := rows.Scan(&one.UserID, &one.Username, &one.IP); err != nil { + return nil, fmt.Errorf("scan address per user: %w", err) + } + out = append(out, one) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list addresses per user: %w", err) + } + return out, nil +} diff --git a/internal/db/message_write.go b/internal/db/message_write.go new file mode 100644 index 00000000..b8e49d70 --- /dev/null +++ b/internal/db/message_write.go @@ -0,0 +1,102 @@ +package db + +import ( + "context" + "fmt" + "strconv" + "time" +) + +var qSendDirectMessage = register("SendDirectMessage", ` +INSERT INTO web_directmessage (sender_id, recipient_id, body, created_at, is_read) +VALUES ($1, $2, $3, $4, false) +RETURNING id`) + +func (d *DB) SendDirectMessage(ctx context.Context, senderID, recipientID int64, body string, at time.Time) (DirectMessage, error) { + out := DirectMessage{SenderID: senderID, RecipientID: recipientID, Body: body, CreatedAt: at} + err := d.pool.QueryRow(ctx, qSendDirectMessage, senderID, recipientID, body, at).Scan(&out.ID) + if err != nil { + return DirectMessage{}, fmt.Errorf("send message from %d to %d: %w", senderID, recipientID, err) + } + return out, nil +} + +var qMarkConversationRead = register("MarkConversationRead", ` +UPDATE web_directmessage +SET is_read = true +WHERE recipient_id = $1 AND sender_id = $2 AND NOT is_read`) + +var qMarkMessageNotificationsViewed = register("MarkMessageNotificationsViewed", ` +UPDATE web_usernotificationmapping m +SET is_viewed = true +FROM web_usernotification n +WHERE m.notification_id = n.id + AND m.recipient_id = $1 + AND NOT m.is_viewed + AND n.type = $3 + AND n.meta->>'sender_id' = $2`) + +func (d *DB) MarkConversationRead(ctx context.Context, userID, partnerID int64) (int64, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin conversation read: %w", err) + } + defer tx.Rollback(ctx) + + tag, err := tx.Exec(ctx, qMarkConversationRead, userID, partnerID) + if err != nil { + return 0, fmt.Errorf("mark conversation with %d read: %w", partnerID, err) + } + read := tag.RowsAffected() + if read > 0 { + partner := strconv.FormatInt(partnerID, 10) + if _, err := tx.Exec(ctx, qMarkMessageNotificationsViewed, userID, partner, NotifyDirectMessage); err != nil { + return 0, fmt.Errorf("mark message notifications of %d viewed: %w", userID, err) + } + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit conversation read: %w", err) + } + return read, nil +} + +var qBlockUser = register("BlockUser", ` +INSERT INTO web_directmessageblock (blocker_id, blocked_id, created_at) +VALUES ($1, $2, $3) +ON CONFLICT (blocker_id, blocked_id) DO NOTHING`) + +func (d *DB) BlockUser(ctx context.Context, blockerID, blockedID int64, at time.Time) (bool, error) { + tag, err := d.pool.Exec(ctx, qBlockUser, blockerID, blockedID, at) + if err != nil { + return false, fmt.Errorf("block %d for %d: %w", blockedID, blockerID, err) + } + return tag.RowsAffected() > 0, nil +} + +var qUnblockUser = register("UnblockUser", ` +DELETE FROM web_directmessageblock +WHERE blocker_id = $1 AND blocked_id = $2`) + +func (d *DB) UnblockUser(ctx context.Context, blockerID, blockedID int64) (bool, error) { + tag, err := d.pool.Exec(ctx, qUnblockUser, blockerID, blockedID) + if err != nil { + return false, fmt.Errorf("unblock %d for %d: %w", blockedID, blockerID, err) + } + return tag.RowsAffected() > 0, nil +} + +var qCreateReport = register("CreateReport", ` +INSERT INTO web_userreport (reporter_id, reported_id, reason, reported_messages, status, admin_notes, created_at, site_id) +VALUES ($1, $2, $3, $4, $5, '', $6, $7) +RETURNING id`) + +func (d *DB) CreateReport(ctx context.Context, siteID, reporterID, reportedID int64, reason, snapshot string, at time.Time) (int64, error) { + var id int64 + err := d.pool.QueryRow(ctx, qCreateReport, reporterID, reportedID, reason, snapshot, ReportPending, at, siteID).Scan(&id) + if err != nil { + return 0, fmt.Errorf("write report by %d against %d: %w", reporterID, reportedID, err) + } + return id, nil +} + +const ReportPending = "pending" diff --git a/internal/db/message_write_test.go b/internal/db/message_write_test.go new file mode 100644 index 00000000..2ff27017 --- /dev/null +++ b/internal/db/message_write_test.go @@ -0,0 +1,203 @@ +package db + +import ( + "context" + "testing" + "time" +) + +func TestSendDirectMessage(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + sender := scratchUser(t, d, "probe-dm-a") + recipient := scratchUser(t, d, "probe-dm-b") + + sent, err := d.SendDirectMessage(ctx, sender, recipient, "hello", time.Now().UTC()) + if err != nil { + t.Fatalf("SendDirectMessage() err = %v, want nil", err) + } + if sent.ID == 0 { + t.Errorf("SendDirectMessage().ID = 0, want a row id") + } + + found, err := d.ConversationBefore(ctx, recipient, sender, nil, 10) + if err != nil { + t.Fatalf("ConversationBefore() err = %v, want nil", err) + } + if len(found) != 1 { + t.Fatalf("len(ConversationBefore()) = %d, want 1", len(found)) + } + if found[0].Body != "hello" { + t.Errorf("ConversationBefore()[0].Body = %q, want %q", found[0].Body, "hello") + } + if found[0].IsRead { + t.Errorf("ConversationBefore()[0].IsRead = true, want false") + } +} + +func TestMarkConversationRead(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + sender := scratchUser(t, d, "probe-dm-read-a") + recipient := scratchUser(t, d, "probe-dm-read-b") + if _, err := d.SendDirectMessage(ctx, sender, recipient, "one", time.Now().UTC()); err != nil { + t.Fatalf("SendDirectMessage() err = %v, want nil", err) + } + + read, err := d.MarkConversationRead(ctx, recipient, sender) + if err != nil { + t.Fatalf("MarkConversationRead() err = %v, want nil", err) + } + if read != 1 { + t.Errorf("MarkConversationRead() = %d, want 1", read) + } + + unread, err := d.UnreadMessages(ctx, recipient) + if err != nil { + t.Fatalf("UnreadMessages() err = %v, want nil", err) + } + if unread != 0 { + t.Errorf("UnreadMessages() = %d, want 0", unread) + } +} + +func TestMarkConversationReadLeavesTheOtherDirection(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + a := scratchUser(t, d, "probe-dm-dir-a") + b := scratchUser(t, d, "probe-dm-dir-b") + if _, err := d.SendDirectMessage(ctx, a, b, "to b", time.Now().UTC()); err != nil { + t.Fatalf("SendDirectMessage() err = %v, want nil", err) + } + if _, err := d.SendDirectMessage(ctx, b, a, "to a", time.Now().UTC()); err != nil { + t.Fatalf("SendDirectMessage() err = %v, want nil", err) + } + + if _, err := d.MarkConversationRead(ctx, b, a); err != nil { + t.Fatalf("MarkConversationRead() err = %v, want nil", err) + } + unread, err := d.UnreadMessages(ctx, a) + if err != nil { + t.Fatalf("UnreadMessages() err = %v, want nil", err) + } + if unread != 1 { + t.Errorf("UnreadMessages(a) = %d, want 1", unread) + } +} + +func TestConversationsCarryTheLastMessage(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + a := scratchUser(t, d, "probe-dm-list-a") + b := scratchUser(t, d, "probe-dm-list-b") + for _, body := range []string{"first", "second"} { + if _, err := d.SendDirectMessage(ctx, a, b, body, time.Now().UTC()); err != nil { + t.Fatalf("SendDirectMessage(%q) err = %v, want nil", body, err) + } + } + + found, err := d.Conversations(ctx, b) + if err != nil { + t.Fatalf("Conversations() err = %v, want nil", err) + } + if len(found) != 1 { + t.Fatalf("len(Conversations()) = %d, want 1", len(found)) + } + if found[0].PartnerID != a { + t.Errorf("Conversations()[0].PartnerID = %d, want %d", found[0].PartnerID, a) + } + if found[0].Last.Body != "second" { + t.Errorf("Conversations()[0].Last.Body = %q, want %q", found[0].Last.Body, "second") + } + if found[0].Unread != 2 { + t.Errorf("Conversations()[0].Unread = %d, want 2", found[0].Unread) + } +} + +func TestMessagesByIDsRefusesAnotherPair(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + a := scratchUser(t, d, "probe-dm-pair-a") + b := scratchUser(t, d, "probe-dm-pair-b") + c := scratchUser(t, d, "probe-dm-pair-c") + sent, err := d.SendDirectMessage(ctx, a, b, "private", time.Now().UTC()) + if err != nil { + t.Fatalf("SendDirectMessage() err = %v, want nil", err) + } + + found, err := d.MessagesByIDs(ctx, a, c, []int64{sent.ID}) + if err != nil { + t.Fatalf("MessagesByIDs() err = %v, want nil", err) + } + if len(found) != 0 { + t.Errorf("len(MessagesByIDs()) = %d, want 0", len(found)) + } +} + +func TestBlockUserOnlyOnce(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + blocker := scratchUser(t, d, "probe-block-a") + blocked := scratchUser(t, d, "probe-block-b") + + first, err := d.BlockUser(ctx, blocker, blocked, time.Now().UTC()) + if err != nil { + t.Fatalf("BlockUser() err = %v, want nil", err) + } + if !first { + t.Errorf("BlockUser() = false, want true") + } + again, err := d.BlockUser(ctx, blocker, blocked, time.Now().UTC()) + if err != nil { + t.Fatalf("BlockUser() err = %v, want nil", err) + } + if again { + t.Errorf("BlockUser() = true, want false") + } + + blockedNow, err := d.IsBlocked(ctx, blocker, blocked) + if err != nil { + t.Fatalf("IsBlocked() err = %v, want nil", err) + } + if !blockedNow { + t.Errorf("IsBlocked() = false, want true") + } + + gone, err := d.UnblockUser(ctx, blocker, blocked) + if err != nil { + t.Fatalf("UnblockUser() err = %v, want nil", err) + } + if !gone { + t.Errorf("UnblockUser() = false, want true") + } +} + +func TestCreateReport(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + reporter := scratchUser(t, d, "probe-report-a") + reported := scratchUser(t, d, "probe-report-b") + + id, err := d.CreateReport(ctx, seedSiteID(t, d), reporter, reported, "spam", `[{"id":1}]`, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateReport() err = %v, want nil", err) + } + got, err := d.Report(ctx, seedSiteID(t, d), id) + if err != nil { + t.Fatalf("Report() err = %v, want nil", err) + } + if got.Reason != "spam" { + t.Errorf("Report().Reason = %q, want %q", got.Reason, "spam") + } + if got.Status != ReportPending { + t.Errorf("Report().Status = %q, want %q", got.Status, ReportPending) + } + + count, err := d.ReportsSince(ctx, seedSiteID(t, d), reporter, reported, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("ReportsSince() err = %v, want nil", err) + } + if count != 1 { + t.Errorf("ReportsSince() = %d, want 1", count) + } +} diff --git a/internal/db/mypages.go b/internal/db/mypages.go new file mode 100644 index 00000000..d6c95ec0 --- /dev/null +++ b/internal/db/mypages.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +type RatedArticle struct { + Article Article + Rate float64 + VotedAt *time.Time +} + +var qRatedByCountOf = register("RatedByCountOf", ` +SELECT count(*) FROM web_vote WHERE user_id = $1`) + +func (d *DB) RatedByCountOf(ctx context.Context, userID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qRatedByCountOf, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("count votes of user %d: %w", userID, err) + } + return n, nil +} + +var qRatedBy = register("RatedBy", ` +SELECT `+prefixedArticleColumns+`, v.rate, v.date +FROM web_vote v +JOIN web_article a ON a.id = v.article_id +WHERE v.user_id = $1 AND a.site_id = $4 +ORDER BY v.date DESC NULLS LAST, v.id DESC +OFFSET $2 LIMIT $3`) + +func (d *DB) RatedBy(ctx context.Context, siteID, userID int64, offset, limit int) ([]RatedArticle, error) { + rows, err := d.pool.Query(ctx, qRatedBy, userID, offset, limit, siteID) + if err != nil { + return nil, fmt.Errorf("list votes of user %d: %w", userID, err) + } + defer rows.Close() + + var out []RatedArticle + for rows.Next() { + var one RatedArticle + a := &one.Article + if err := rows.Scan(&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, + &a.Locked, &a.CreatedAt, &a.UpdatedAt, &a.MediaName, &one.Rate, &one.VotedAt); err != nil { + return nil, fmt.Errorf("scan vote: %w", err) + } + out = append(out, one) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list votes of user %d: %w", userID, err) + } + return out, nil +} + +type LikedPost struct { + Post ForumThreadPost + ThreadName string + LikedAt time.Time +} + +var qLikedPostCountOf = register("LikedPostCountOf", ` +SELECT count(*) FROM web_forumpostlike WHERE user_id = $1`) + +func (d *DB) LikedPostCountOf(ctx context.Context, userID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qLikedPostCountOf, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("count likes of user %d: %w", userID, err) + } + return n, nil +} + +var qLikedPostsOf = register("LikedPostsOf", ` +SELECT p.id, p.thread_id, p.name, p.created_at, p.updated_at, p.author_id, p.reply_to_id, + coalesce(t.name, ''), l.created_at +FROM web_forumpostlike l +JOIN web_forumpost p ON p.id = l.post_id +JOIN web_forumthread t ON t.id = p.thread_id +WHERE l.user_id = $1 AND t.site_id = $4 +ORDER BY l.created_at DESC, l.id DESC +OFFSET $2 LIMIT $3`) + +func (d *DB) LikedPostsOf(ctx context.Context, siteID, userID int64, offset, limit int) ([]LikedPost, error) { + rows, err := d.pool.Query(ctx, qLikedPostsOf, userID, offset, limit, siteID) + if err != nil { + return nil, fmt.Errorf("list likes of user %d: %w", userID, err) + } + defer rows.Close() + + var out []LikedPost + for rows.Next() { + var one LikedPost + p := &one.Post + if err := rows.Scan(&p.ID, &p.ThreadID, &p.Name, &p.CreatedAt, &p.UpdatedAt, + &p.AuthorID, &p.ReplyToID, &one.ThreadName, &one.LikedAt); err != nil { + return nil, fmt.Errorf("scan like: %w", err) + } + out = append(out, one) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list likes of user %d: %w", userID, err) + } + return out, nil +} diff --git a/internal/db/notification.go b/internal/db/notification.go new file mode 100644 index 00000000..64ca028c --- /dev/null +++ b/internal/db/notification.go @@ -0,0 +1,116 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +var qUnreadNotifications = register("UnreadNotifications", ` +SELECT count(*) +FROM web_usernotificationmapping +WHERE recipient_id = $1 AND is_viewed = false`) + +func (d *DB) UnreadNotifications(ctx context.Context, userID int64) (int, error) { + var n int + if err := d.pool.QueryRow(ctx, qUnreadNotifications, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("count unread notifications of user %d: %w", userID, err) + } + return n, nil +} + +type Notification struct { + ID int64 + Type string + Meta []byte + CreatedAt time.Time + IsViewed bool +} + +// A null kind list asks for every type, which is what the unfiltered view wants. +var qNotificationsOf = register("NotificationsOf", ` +SELECT n.id, n.type, n.meta, n.created_at, m.is_viewed +FROM web_usernotificationmapping m +JOIN web_usernotification n ON n.id = m.notification_id +WHERE m.recipient_id = $1 + AND ($2::bigint IS NULL OR n.id < $2) + AND (NOT $3 OR m.is_viewed = false) + AND ($4::text[] IS NULL OR n.type = ANY($4)) +ORDER BY n.id DESC +LIMIT $5`) + +func (d *DB) NotificationsOf(ctx context.Context, userID int64, cursor *int64, + unread bool, kinds []string, limit int) ([]Notification, error) { + + var kindFilter any + if len(kinds) > 0 { + kindFilter = kinds + } + rows, err := d.pool.Query(ctx, qNotificationsOf, userID, cursor, unread, kindFilter, limit) + if err != nil { + return nil, fmt.Errorf("list notifications of user %d: %w", userID, err) + } + defer rows.Close() + + var out []Notification + for rows.Next() { + var n Notification + if err := rows.Scan(&n.ID, &n.Type, &n.Meta, &n.CreatedAt, &n.IsViewed); err != nil { + return nil, fmt.Errorf("scan notification: %w", err) + } + out = append(out, n) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list notifications of user %d: %w", userID, err) + } + return out, nil +} + +var qMarkNotificationsViewed = register("MarkNotificationsViewed", ` +UPDATE web_usernotificationmapping SET is_viewed = true +WHERE recipient_id = $1 AND notification_id = ANY($2)`) + +func (d *DB) MarkNotificationsViewed(ctx context.Context, userID int64, ids []int64) error { + if len(ids) == 0 { + return nil + } + if _, err := d.pool.Exec(ctx, qMarkNotificationsViewed, userID, ids); err != nil { + return fmt.Errorf("mark notifications of user %d: %w", userID, err) + } + return nil +} + +// Only the reader's own row goes, so a notification sent to several people +// survives for everyone who has not cleared it. +var qDeleteNotifications = register("DeleteNotifications", ` +DELETE FROM web_usernotificationmapping +WHERE recipient_id = $1 AND notification_id = ANY($2)`) + +func (d *DB) DeleteNotifications(ctx context.Context, userID int64, ids []int64) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + tag, err := d.pool.Exec(ctx, qDeleteNotifications, userID, ids) + if err != nil { + return 0, fmt.Errorf("delete notifications of user %d: %w", userID, err) + } + return tag.RowsAffected(), nil +} + +var qDeleteAllNotifications = register("DeleteAllNotifications", ` +DELETE FROM web_usernotificationmapping +WHERE recipient_id = $1 + AND ($2::text[] IS NULL OR notification_id IN ( + SELECT id FROM web_usernotification WHERE type = ANY($2)))`) + +func (d *DB) DeleteAllNotifications(ctx context.Context, userID int64, kinds []string) (int64, error) { + var kindFilter any + if len(kinds) > 0 { + kindFilter = kinds + } + tag, err := d.pool.Exec(ctx, qDeleteAllNotifications, userID, kindFilter) + if err != nil { + return 0, fmt.Errorf("clear notifications of user %d: %w", userID, err) + } + return tag.RowsAffected(), nil +} diff --git a/internal/db/notify_write.go b/internal/db/notify_write.go new file mode 100644 index 00000000..72dcfff6 --- /dev/null +++ b/internal/db/notify_write.go @@ -0,0 +1,133 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +const ( + NotifyWelcome = "welcome" + NotifyNewPostReply = "new_post_reply" + NotifyNewThreadPost = "new_thread_post" + NotifyNewArticleRevision = "new_article_revision" + NotifyForumMention = "forum_mention" + NotifyDirectMessage = "direct_message" + NotifyPostLike = "post_like" +) + +var ( + qInsertNotification = register("InsertNotification", ` +INSERT INTO web_usernotification (type, meta, created_at) +VALUES ($1, $2, $3) +RETURNING id`) + + qInsertNotificationMappings = register("InsertNotificationMappings", ` +INSERT INTO web_usernotificationmapping (notification_id, recipient_id, is_viewed) +SELECT $1, recipient, false +FROM unnest($2::bigint[]) AS recipient`) +) + +// The notification and the rows naming its readers go in together, so nobody +// ends up with a notification that reaches no one. +func (d *DB) SendNotification(ctx context.Context, kind, meta string, recipients []int64, at time.Time) error { + if len(recipients) == 0 { + return nil + } + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin notification: %w", err) + } + defer tx.Rollback(ctx) + + var id int64 + if err := tx.QueryRow(ctx, qInsertNotification, kind, meta, at).Scan(&id); err != nil { + return fmt.Errorf("write notification %q: %w", kind, err) + } + if _, err := tx.Exec(ctx, qInsertNotificationMappings, id, recipients); err != nil { + return fmt.Errorf("write recipients of notification %d: %w", id, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit notification %q: %w", kind, err) + } + return nil +} + +var qArticleSubscribers = register("ArticleSubscribers", ` +SELECT subscriber_id +FROM web_usernotificationsubscription +WHERE article_id = $1 +ORDER BY subscriber_id`) + +func (d *DB) ArticleSubscribers(ctx context.Context, articleID int64) ([]int64, error) { + return d.subscriberIDs(ctx, qArticleSubscribers, articleID) +} + +var qThreadSubscribers = register("ThreadSubscribers", ` +SELECT subscriber_id +FROM web_usernotificationsubscription +WHERE forum_thread_id = $1 +ORDER BY subscriber_id`) + +func (d *DB) ThreadSubscribers(ctx context.Context, threadID int64) ([]int64, error) { + return d.subscriberIDs(ctx, qThreadSubscribers, threadID) +} + +func (d *DB) subscriberIDs(ctx context.Context, sql string, id int64) ([]int64, error) { + rows, err := d.pool.Query(ctx, sql, id) + if err != nil { + return nil, fmt.Errorf("list subscribers of %d: %w", id, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var one int64 + if err := rows.Scan(&one); err != nil { + return nil, fmt.Errorf("scan subscriber: %w", err) + } + out = append(out, one) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list subscribers of %d: %w", id, err) + } + return out, nil +} + +var qSubscribeToThread = register("SubscribeToThread", ` +INSERT INTO web_usernotificationsubscription (subscriber_id, forum_thread_id) +SELECT $1, $2 +WHERE NOT EXISTS ( + SELECT 1 FROM web_usernotificationsubscription + WHERE subscriber_id = $1 AND forum_thread_id = $2)`) + +func (d *DB) SubscribeToThread(ctx context.Context, userID, threadID int64) error { + if _, err := d.pool.Exec(ctx, qSubscribeToThread, userID, threadID); err != nil { + return fmt.Errorf("subscribe %d to thread %d: %w", userID, threadID, err) + } + return nil +} + +var qUnsubscribeFromArticle = register("UnsubscribeFromArticle", ` +DELETE FROM web_usernotificationsubscription +WHERE subscriber_id = $1 AND article_id = $2`) + +func (d *DB) UnsubscribeFromArticle(ctx context.Context, userID, articleID int64) (bool, error) { + tag, err := d.pool.Exec(ctx, qUnsubscribeFromArticle, userID, articleID) + if err != nil { + return false, fmt.Errorf("unsubscribe %d from article %d: %w", userID, articleID, err) + } + return tag.RowsAffected() > 0, nil +} + +var qUnsubscribeFromThread = register("UnsubscribeFromThread", ` +DELETE FROM web_usernotificationsubscription +WHERE subscriber_id = $1 AND forum_thread_id = $2`) + +func (d *DB) UnsubscribeFromThread(ctx context.Context, userID, threadID int64) (bool, error) { + tag, err := d.pool.Exec(ctx, qUnsubscribeFromThread, userID, threadID) + if err != nil { + return false, fmt.Errorf("unsubscribe %d from thread %d: %w", userID, threadID, err) + } + return tag.RowsAffected() > 0, nil +} diff --git a/internal/db/notify_write_test.go b/internal/db/notify_write_test.go new file mode 100644 index 00000000..20eed479 --- /dev/null +++ b/internal/db/notify_write_test.go @@ -0,0 +1,188 @@ +package db + +import ( + "context" + "testing" + "time" +) + +func scratchUser(t *testing.T, d *DB, name string) int64 { + t.Helper() + ctx := context.Background() + name = name + "-" + time.Now().Format("150405.000000") + var id int64 + err := d.pool.QueryRow(ctx, ` +INSERT INTO web_user (password, is_superuser, first_name, last_name, email, date_joined, + username, type, bio, is_forum_active, is_active, can_send_direct_messages, + pending_email, previous_email) +VALUES ('!', false, '', '', $1, now(), $2, 'normal', '', true, true, true, '', '') +RETURNING id`, name+"@example.invalid", name).Scan(&id) + if err != nil { + t.Fatalf("insert scratch user err = %v, want nil", err) + } + t.Cleanup(func() { + clean := context.Background() + for _, sql := range []string{ + `DELETE FROM web_usernotificationmapping WHERE recipient_id = $1`, + `DELETE FROM web_usernotificationsubscription WHERE subscriber_id = $1`, + `DELETE FROM web_forumpostlike WHERE user_id = $1`, + `DELETE FROM web_articlefavourite WHERE user_id = $1`, + `DELETE FROM web_directmessage WHERE sender_id = $1 OR recipient_id = $1`, + `DELETE FROM web_directmessageblock WHERE blocker_id = $1 OR blocked_id = $1`, + `DELETE FROM web_userreport WHERE reporter_id = $1 OR reported_id = $1`, + `DELETE FROM web_user WHERE id = $1`, + } { + if _, err := d.pool.Exec(clean, sql, id); err != nil { + t.Errorf("clean up scratch user err = %v, want nil", err) + } + } + }) + return id +} + +func dropNotification(t *testing.T, d *DB, after int64) { + t.Helper() + t.Cleanup(func() { + clean := context.Background() + for _, sql := range []string{ + `DELETE FROM web_usernotificationmapping WHERE notification_id > $1`, + `DELETE FROM web_usernotification WHERE id > $1`, + } { + if _, err := d.pool.Exec(clean, sql, after); err != nil { + t.Errorf("clean up notification err = %v, want nil", err) + } + } + }) +} + +func highestNotification(t *testing.T, d *DB) int64 { + t.Helper() + var id int64 + err := d.pool.QueryRow(context.Background(), + `SELECT COALESCE(MAX(id), 0) FROM web_usernotification`).Scan(&id) + if err != nil { + t.Fatalf("read highest notification err = %v, want nil", err) + } + return id +} + +func TestSendNotificationReachesEveryRecipient(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + dropNotification(t, d, highestNotification(t, d)) + first := scratchUser(t, d, "probe-notify-a") + second := scratchUser(t, d, "probe-notify-b") + + err := d.SendNotification(ctx, NotifyNewArticleRevision, `{"probe": true}`, + []int64{first, second}, time.Now().UTC()) + if err != nil { + t.Fatalf("SendNotification() err = %v, want nil", err) + } + + var count int + err = d.pool.QueryRow(ctx, ` +SELECT count(*) FROM web_usernotificationmapping m +JOIN web_usernotification n ON n.id = m.notification_id +WHERE n.type = $1 AND m.recipient_id = ANY($2) AND m.is_viewed = false`, + NotifyNewArticleRevision, []int64{first, second}).Scan(&count) + if err != nil { + t.Fatalf("count recipients err = %v, want nil", err) + } + if count != 2 { + t.Errorf("SendNotification() reached %d recipients, want 2", count) + } +} + +func TestSendNotificationToNobodyWritesNothing(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + before := highestNotification(t, d) + + if err := d.SendNotification(ctx, NotifyWelcome, `{}`, nil, time.Now().UTC()); err != nil { + t.Fatalf("SendNotification() err = %v, want nil", err) + } + if got := highestNotification(t, d); got != before { + t.Errorf("highest notification = %d, want %d", got, before) + } +} + +func TestArticleSubscribersListsWhoAsked(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + first := scratchUser(t, d, "probe-sub-a") + second := scratchUser(t, d, "probe-sub-b") + + for _, id := range []int64{first, second} { + if err := d.SubscribeToArticle(ctx, id, article); err != nil { + t.Fatalf("SubscribeToArticle(%d) err = %v, want nil", id, err) + } + } + got, err := d.ArticleSubscribers(ctx, article) + if err != nil { + t.Fatalf("ArticleSubscribers() err = %v, want nil", err) + } + if len(got) != 2 { + t.Fatalf("len(ArticleSubscribers()) = %d, want 2", len(got)) + } + if got[0] != first || got[1] != second { + t.Errorf("ArticleSubscribers() = %v, want [%d %d]", got, first, second) + } +} + +func TestSubscribeToThreadOnlyOnce(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + user := scratchUser(t, d, "probe-sub-thread") + var thread int64 + if err := d.pool.QueryRow(ctx, `SELECT id FROM web_forumthread ORDER BY id LIMIT 1`).Scan(&thread); err != nil { + t.Skipf("no forum thread in the write database, skipping") + } + + for i := 0; i < 2; i++ { + if err := d.SubscribeToThread(ctx, user, thread); err != nil { + t.Fatalf("SubscribeToThread() err = %v, want nil", err) + } + } + got, err := d.ThreadSubscribers(ctx, thread) + if err != nil { + t.Fatalf("ThreadSubscribers() err = %v, want nil", err) + } + seen := 0 + for _, id := range got { + if id == user { + seen++ + } + } + if seen != 1 { + t.Errorf("ThreadSubscribers() holds the subscriber %d times, want 1", seen) + } +} + +func TestLogEntryByIDReadsWhatWasWritten(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + + rev, err := d.AddArticleLogEntry(ctx, article, nil, LogTitle, "why", `{"title": "After"}`, time.Now().UTC()) + if err != nil { + t.Fatalf("AddArticleLogEntry() err = %v, want nil", err) + } + if rev.EntryID == 0 { + t.Fatal("AddArticleLogEntry().EntryID = 0, want the row id") + } + + entry, err := d.LogEntryByID(ctx, rev.EntryID) + if err != nil { + t.Fatalf("LogEntryByID() err = %v, want nil", err) + } + if entry.Type != LogTitle { + t.Errorf("LogEntryByID().Type = %q, want %q", entry.Type, LogTitle) + } + if entry.Comment != "why" { + t.Errorf("LogEntryByID().Comment = %q, want %q", entry.Comment, "why") + } + if entry.RevNumber != rev.RevNumber { + t.Errorf("LogEntryByID().RevNumber = %d, want %d", entry.RevNumber, rev.RevNumber) + } +} diff --git a/internal/db/options.go b/internal/db/options.go new file mode 100644 index 00000000..56fd4682 --- /dev/null +++ b/internal/db/options.go @@ -0,0 +1,231 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +const ( + CreateTagsDefault = "default" + CreateTagsDisabled = "disabled" + CreateTagsEnabled = "enabled" +) + +var qSiteCanCreateTags = register("SiteCanCreateTags", ` +SELECT can_user_create_tags +FROM web_settings +WHERE site_id = $1`) + +// SiteCanCreateTags reads the site row alone. The page asks Site.settings, +// which never merges the category or the built-in defaults into it. +func (d *DB) SiteCanCreateTags(ctx context.Context, siteID int64) (string, error) { + var mode string + err := d.pool.QueryRow(ctx, qSiteCanCreateTags, siteID).Scan(&mode) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query tag setting of site %d: %w", siteID, err) + } + return mode, nil +} + +var qCategoryCanCreateTags = register("CategoryCanCreateTags", ` +SELECT s.can_user_create_tags +FROM web_settings s +JOIN web_category c ON c.id = s.category_id +WHERE c.site_id = $1 AND c.name = $2`) + +// A category with no row of its own reads the same as one left on default. Both +// hand the question back to the site. +func (d *DB) CategoryCanCreateTags(ctx context.Context, siteID int64, category string) (string, error) { + var mode string + err := d.pool.QueryRow(ctx, qCategoryCanCreateTags, siteID, category).Scan(&mode) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query tag setting of category %q: %w", category, err) + } + return mode, nil +} + +// Creating tags is off unless something turns it on, and the category has the +// last word. +func CreatingTagsAllowed(siteMode, categoryMode string) bool { + mode := CreateTagsDisabled + if siteMode != "" && siteMode != CreateTagsDefault { + mode = siteMode + } + if categoryMode != "" && categoryMode != CreateTagsDefault { + mode = categoryMode + } + return mode == CreateTagsEnabled +} + +type CommentInfo struct { + ThreadID int64 + Count int +} + +var qCommentInfo = register("CommentInfo", ` +SELECT t.id, (SELECT count(*) FROM web_forumpost p WHERE p.thread_id = t.id) +FROM web_forumthread t +WHERE t.article_id = $1`) + +// A page whose thread has never been created reports the zero value rather than +// having one written for it on a read. +func (d *DB) CommentInfo(ctx context.Context, articleID int64) (CommentInfo, error) { + var info CommentInfo + err := d.pool.QueryRow(ctx, qCommentInfo, articleID).Scan(&info.ThreadID, &info.Count) + if errors.Is(err, pgx.ErrNoRows) { + return CommentInfo{}, nil + } + if err != nil { + return CommentInfo{}, fmt.Errorf("query comment thread of article %d: %w", articleID, err) + } + return info, nil +} + +var qSubscribedToArticle = register("SubscribedToArticle", ` +SELECT EXISTS( + SELECT 1 FROM web_usernotificationsubscription + WHERE subscriber_id = $1 AND article_id = $2 AND forum_thread_id IS NULL)`) + +func (d *DB) SubscribedToArticle(ctx context.Context, userID, articleID int64) (bool, error) { + var yes bool + if err := d.pool.QueryRow(ctx, qSubscribedToArticle, userID, articleID).Scan(&yes); err != nil { + return false, fmt.Errorf("check article subscription of user %d: %w", userID, err) + } + return yes, nil +} + +var qSubscribedToThread = register("SubscribedToThread", ` +SELECT EXISTS( + SELECT 1 FROM web_usernotificationsubscription + WHERE subscriber_id = $1 AND forum_thread_id = $2 AND article_id IS NULL)`) + +func (d *DB) SubscribedToThread(ctx context.Context, userID, threadID int64) (bool, error) { + var yes bool + if err := d.pool.QueryRow(ctx, qSubscribedToThread, userID, threadID).Scan(&yes); err != nil { + return false, fmt.Errorf("check thread subscription of user %d: %w", userID, err) + } + return yes, nil +} + +var qUserPreference = register("UserPreference", ` +SELECT raw_value +FROM dynamic_preferences_users_userpreferencemodel +WHERE instance_id = $1 AND section = $2 AND name = $3`) + +func (d *DB) UserPreference(ctx context.Context, userID int64, section, name string) (string, error) { + var raw string + err := d.pool.QueryRow(ctx, qUserPreference, userID, section, name).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query preference %s.%s of user %d: %w", section, name, userID, err) + } + return raw, nil +} + +var qTagCategoryBySlug = register("TagCategoryBySlug", ` +SELECT id, name, priority +FROM web_tagscategory +WHERE slug = $1 AND site_id = $2`) + +func (d *DB) TagCategoryBySlug(ctx context.Context, siteID int64, slug string) (TagCategory, error) { + var c TagCategory + err := d.pool.QueryRow(ctx, qTagCategoryBySlug, slug, siteID).Scan(&c.ID, &c.Name, &c.Priority) + if errors.Is(err, pgx.ErrNoRows) { + return TagCategory{}, ErrNotFound + } + if err != nil { + return TagCategory{}, fmt.Errorf("lookup tag category %q: %w", slug, err) + } + return c, nil +} + +var qCategoryNames = register("CategoryNames", `SELECT name FROM web_category WHERE site_id = $1`) + +func (d *DB) CategoryNames(ctx context.Context, siteID int64) ([]string, error) { + rows, err := d.pool.Query(ctx, qCategoryNames, siteID) + if err != nil { + return nil, fmt.Errorf("list categories: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan category name: %w", err) + } + out = append(out, name) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read categories: %w", err) + } + return out, nil +} + +var qArticlesByTag = register("ArticlesByTag", ` +SELECT `+prefixedArticleColumns+` +FROM web_article a +JOIN web_article_tags link ON link.article_id = a.id +JOIN web_tag t ON t.id = link.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE c.slug = $1 AND t.name = $2 AND NOT (a.category = ANY($3)) AND a.site_id = $4 +ORDER BY a.title`) + +// ArticlesByTag leaves out the categories the visitor cannot see, which the +// caller resolves because permissions are not a database question. +func (d *DB) ArticlesByTag(ctx context.Context, siteID int64, categorySlug, name string, hidden []string) ([]Article, error) { + if hidden == nil { + hidden = []string{} + } + rows, err := d.pool.Query(ctx, qArticlesByTag, categorySlug, name, hidden, siteID) + if err != nil { + return nil, fmt.Errorf("query articles tagged %q: %w", name, err) + } + defer rows.Close() + + var out []Article + for rows.Next() { + var a Article + if err := rows.Scan(&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, + &a.Locked, &a.CreatedAt, &a.UpdatedAt, &a.MediaName); err != nil { + return nil, fmt.Errorf("scan article tagged %q: %w", name, err) + } + out = append(out, a) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read articles tagged %q: %w", name, err) + } + return out, nil +} + +var qCommentThreadFor = register("CommentThreadFor", ` +WITH existing AS ( + SELECT id FROM web_forumthread WHERE article_id = $1 +), created AS ( + INSERT INTO web_forumthread (site_id, name, description, article_id, is_pinned, is_locked, created_at, updated_at) + SELECT $2, '', '', $1, false, false, now(), now() + WHERE NOT EXISTS (SELECT 1 FROM existing) + RETURNING id +) +SELECT id FROM existing UNION ALL SELECT id FROM created`) + +// A page gets its comment thread the first time a reader asks for the +// discussion, so reading the page never writes and the link still lands. +func (d *DB) CommentThreadFor(ctx context.Context, siteID, articleID int64) (int64, error) { + var id int64 + if err := d.pool.QueryRow(ctx, qCommentThreadFor, articleID, siteID).Scan(&id); err != nil { + return 0, fmt.Errorf("open comment thread of article %d: %w", articleID, err) + } + return id, nil +} diff --git a/internal/db/options_test.go b/internal/db/options_test.go new file mode 100644 index 00000000..ec2c9160 --- /dev/null +++ b/internal/db/options_test.go @@ -0,0 +1,128 @@ +package db + +import ( + "context" + "errors" + "testing" +) + +func TestCommentInfoOfPageWithPosts(t *testing.T) { + d := newTestDB(t) + + got, err := d.CommentInfo(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("CommentInfo() err = %v, want nil", err) + } + if got.ThreadID == 0 { + t.Errorf("CommentInfo(probe:full).ThreadID = 0, want a thread id") + } + if got.Count != 2 { + t.Errorf("CommentInfo(probe:full).Count = %d, want 2", got.Count) + } +} + +func TestCommentInfoOfPageWithoutThread(t *testing.T) { + d := newTestDB(t) + + got, err := d.CommentInfo(context.Background(), 0) + if err != nil { + t.Fatalf("CommentInfo() err = %v, want nil", err) + } + if got != (CommentInfo{}) { + t.Errorf("CommentInfo(0) = %+v, want the zero value", got) + } +} + +func TestCommentInfoOfPageWithoutPosts(t *testing.T) { + d := newTestDB(t) + + got, err := d.CommentInfo(context.Background(), articleID(t, d, "probe:bare")) + if err != nil { + t.Fatalf("CommentInfo() err = %v, want nil", err) + } + if got.Count != 0 { + t.Errorf("CommentInfo(probe:bare).Count = %d, want 0", got.Count) + } +} + +func TestSubscribedToArticle(t *testing.T) { + d := newTestDB(t) + author := userID(t, d, "probe-author") + + got, err := d.SubscribedToArticle(context.Background(), author, articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("SubscribedToArticle() err = %v, want nil", err) + } + if !got { + t.Errorf("SubscribedToArticle(probe-author, probe:full) = false, want true") + } +} + +func TestSubscribedToArticleOfAnotherPage(t *testing.T) { + d := newTestDB(t) + author := userID(t, d, "probe-author") + + got, err := d.SubscribedToArticle(context.Background(), author, articleID(t, d, "probe:bare")) + if err != nil { + t.Fatalf("SubscribedToArticle() err = %v, want nil", err) + } + if got { + t.Errorf("SubscribedToArticle(probe-author, probe:bare) = true, want false") + } +} + +func TestSubscribedToThread(t *testing.T) { + d := newTestDB(t) + author := userID(t, d, "probe-author") + + info, err := d.CommentInfo(context.Background(), articleID(t, d, "probe:full")) + if err != nil { + t.Fatalf("CommentInfo() err = %v, want nil", err) + } + got, err := d.SubscribedToThread(context.Background(), author, info.ThreadID) + if err != nil { + t.Fatalf("SubscribedToThread() err = %v, want nil", err) + } + if !got { + t.Errorf("SubscribedToThread(probe-author, %d) = false, want true", info.ThreadID) + } +} + +func TestUserPreferenceStoresPythonRepr(t *testing.T) { + d := newTestDB(t) + + got, err := d.UserPreference(context.Background(), userID(t, d, "probe-author"), + "qol", "advanced_source_editor_enabled") + if err != nil { + t.Fatalf("UserPreference() err = %v, want nil", err) + } + if got != "True" { + t.Errorf("UserPreference(probe-author) = %q, want %q", got, "True") + } +} + +func TestUserPreferenceUnset(t *testing.T) { + d := newTestDB(t) + + _, err := d.UserPreference(context.Background(), userID(t, d, "probevoter"), + "qol", "advanced_source_editor_enabled") + if !errors.Is(err, ErrNotFound) { + t.Errorf("UserPreference(probevoter) err = %v, want ErrNotFound", err) + } +} + +func TestSiteCanCreateTagsReadsTheSiteRowAlone(t *testing.T) { + d := newTestDB(t) + + site, err := d.SiteByHosts(context.Background(), []string{"localhost"}) + if err != nil { + t.Fatalf("SiteByHosts() err = %v, want nil", err) + } + got, err := d.SiteCanCreateTags(context.Background(), site.ID) + if err != nil { + t.Fatalf("SiteCanCreateTags() err = %v, want nil", err) + } + if got != CreateTagsDisabled && got != CreateTagsEnabled { + t.Errorf("SiteCanCreateTags() = %q, want %q or %q", got, CreateTagsDisabled, CreateTagsEnabled) + } +} diff --git a/internal/db/page_admin.go b/internal/db/page_admin.go new file mode 100644 index 00000000..ecc54850 --- /dev/null +++ b/internal/db/page_admin.go @@ -0,0 +1,100 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +type AdminPageRow struct { + ID int64 + Category string + Name string + Title string + Revisions int + UpdatedAt time.Time +} + +func (p *AdminPageRow) FullName() string { + if p.Category != DefaultCategory { + return p.Category + ":" + p.Name + } + return p.Name +} + +const adminPageWhere = ` +WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%' OR a.title ILIKE '%' || $1 || '%') + AND ($2 = '' OR a.category = $2) + AND a.site_id = $3` + +var qAdminPages = register("AdminPages", ` +SELECT a.id, a.category, a.name, coalesce(a.title, ''), a.updated_at, + (SELECT count(*) FROM web_articlelogentry l WHERE l.article_id = a.id) +FROM web_article a`+adminPageWhere+` +ORDER BY a.updated_at DESC, a.id DESC +LIMIT $4 OFFSET $5`) + +var qAdminPageCount = register("AdminPageCount", ` +SELECT count(*) FROM web_article a`+adminPageWhere) + +func (d *DB) AdminPages(ctx context.Context, siteID int64, query, category string, limit, offset int) ([]AdminPageRow, int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAdminPageCount, query, category, siteID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count pages: %w", err) + } + rows, err := d.pool.Query(ctx, qAdminPages, query, category, siteID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("list pages: %w", err) + } + defer rows.Close() + + var out []AdminPageRow + for rows.Next() { + var p AdminPageRow + if err := rows.Scan(&p.ID, &p.Category, &p.Name, &p.Title, &p.UpdatedAt, &p.Revisions); err != nil { + return nil, 0, err + } + out = append(out, p) + } + return out, total, rows.Err() +} + +var qAdminPageCategories = register("AdminPageCategories", ` +SELECT category, count(*) FROM web_article WHERE site_id = $1 GROUP BY category ORDER BY category`) + +type PageCategoryCount struct { + Category string + Pages int +} + +func (d *DB) AdminPageCategories(ctx context.Context, siteID int64) ([]PageCategoryCount, error) { + rows, err := d.pool.Query(ctx, qAdminPageCategories, siteID) + if err != nil { + return nil, fmt.Errorf("list page categories: %w", err) + } + defer rows.Close() + + var out []PageCategoryCount + for rows.Next() { + var one PageCategoryCount + if err := rows.Scan(&one.Category, &one.Pages); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} + +var qSetArticleIndexed = register("SetArticleIndexed", ` +UPDATE web_article SET is_indexed = $3 WHERE id = $1 AND site_id = $2`) + +func (d *DB) SetArticleIndexed(ctx context.Context, siteID, id int64, indexed bool) error { + tag, err := d.pool.Exec(ctx, qSetArticleIndexed, id, siteID, indexed) + if err != nil { + return fmt.Errorf("set indexed on article %d: %w", id, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/db/perms.go b/internal/db/perms.go new file mode 100644 index 00000000..06b9e0db --- /dev/null +++ b/internal/db/perms.go @@ -0,0 +1,185 @@ +package db + +import ( + "context" + "fmt" + + "github.com/WikitTeam/ProjectWikit/internal/perms" +) + +var qRoleIDsBySlug = register("RoleIDsBySlug", ` +SELECT slug, id +FROM web_role +WHERE site_id = $1 AND slug = ANY($2)`) + +// Nothing downstream depends on the order. Each role's permissions are settled +// on their own and then merged. +var qRoleIDsForUser = register("RoleIDsForUser", ` +SELECT ur.role_id +FROM web_user_roles ur +JOIN web_role r ON r.id = ur.role_id +WHERE ur.user_id = $1 AND r.site_id = $2 +ORDER BY r.index DESC, r.id`) + +var qRolePermissions = register("RolePermissions", ` +SELECT rp.role_id, p.codename, false AS restricted +FROM web_role_permissions rp +JOIN auth_permission p ON p.id = rp.permission_id +WHERE rp.role_id = ANY($1) +UNION ALL +SELECT rr.role_id, p.codename, true +FROM web_role_restrictions rr +JOIN auth_permission p ON p.id = rr.permission_id +WHERE rr.role_id = ANY($1)`) + +// An override with no permissions at all still has to come back: it takes the +// one slot its role reads, and the rows after it are never looked at. +var qCategoryOverrides = register("CategoryOverrides", ` +SELECT o.id, o.role_id, op.codename, op.restricted +FROM web_category c +JOIN web_category_permissions_override cpo ON cpo.category_id = c.id +JOIN web_rolepermissionsoverride o ON o.id = cpo.rolepermissionsoverride_id +LEFT JOIN ( + SELECT x.rolepermissionsoverride_id AS override_id, p.codename, false AS restricted + FROM web_rolepermissionsoverride_permissions x + JOIN auth_permission p ON p.id = x.permission_id + UNION ALL + SELECT x.rolepermissionsoverride_id, p.codename, true + FROM web_rolepermissionsoverride_restrictions x + JOIN auth_permission p ON p.id = x.permission_id +) op ON op.override_id = o.id +WHERE c.site_id = $1 AND c.name = $2 +ORDER BY o.id`) + +var qArticleHasAuthor = register("ArticleHasAuthor", ` +SELECT EXISTS ( + SELECT 1 FROM web_article_authors WHERE article_id = $1 AND user_id = $2 +)`) + +// A slug with no row is left out rather than created, because a read path must +// not write. +func (d *DB) RoleIDsBySlug(ctx context.Context, siteID int64, slugs []string) (map[string]int64, error) { + rows, err := d.pool.Query(ctx, qRoleIDsBySlug, siteID, slugs) + if err != nil { + return nil, fmt.Errorf("look up roles %v: %w", slugs, err) + } + defer rows.Close() + + out := make(map[string]int64, len(slugs)) + for rows.Next() { + var slug string + var id int64 + if err := rows.Scan(&slug, &id); err != nil { + return nil, fmt.Errorf("scan role slug: %w", err) + } + out[slug] = id + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("look up roles %v: %w", slugs, err) + } + return out, nil +} + +func (d *DB) RoleIDsForUser(ctx context.Context, siteID, userID int64) ([]int64, error) { + rows, err := d.pool.Query(ctx, qRoleIDsForUser, userID, siteID) + if err != nil { + return nil, fmt.Errorf("list role ids of user %d: %w", userID, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan role id of user %d: %w", userID, err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list role ids of user %d: %w", userID, err) + } + return out, nil +} + +// RolePermissions answers in the order the ids arrived, and a role with no rows +// of its own still comes back so the merge sees it. +func (d *DB) RolePermissions(ctx context.Context, ids []int64) ([]perms.Role, error) { + byID := make(map[int64]*perms.Role, len(ids)) + out := make([]perms.Role, len(ids)) + for i, id := range ids { + out[i].ID = id + byID[id] = &out[i] + } + + rows, err := d.pool.Query(ctx, qRolePermissions, ids) + if err != nil { + return nil, fmt.Errorf("list permissions of roles %v: %w", ids, err) + } + defer rows.Close() + + for rows.Next() { + var roleID int64 + var codename string + var restricted bool + if err := rows.Scan(&roleID, &codename, &restricted); err != nil { + return nil, fmt.Errorf("scan role permission: %w", err) + } + role, ok := byID[roleID] + if !ok { + continue + } + if restricted { + role.Restrictions = append(role.Restrictions, codename) + continue + } + role.Permissions = append(role.Permissions, codename) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list permissions of roles %v: %w", ids, err) + } + return out, nil +} + +func (d *DB) CategoryOverrides(ctx context.Context, siteID int64, category string) ([]perms.Override, error) { + rows, err := d.pool.Query(ctx, qCategoryOverrides, siteID, category) + if err != nil { + return nil, fmt.Errorf("list permission overrides of category %q: %w", category, err) + } + defer rows.Close() + + var out []perms.Override + var current int64 + for rows.Next() { + var id, roleID int64 + var codename *string + var restricted *bool + if err := rows.Scan(&id, &roleID, &codename, &restricted); err != nil { + return nil, fmt.Errorf("scan permission override of category %q: %w", category, err) + } + if len(out) == 0 || current != id { + out = append(out, perms.Override{RoleID: roleID}) + current = id + } + if codename == nil { + continue + } + override := &out[len(out)-1] + if *restricted { + override.Restrictions = append(override.Restrictions, *codename) + continue + } + override.Permissions = append(override.Permissions, *codename) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list permission overrides of category %q: %w", category, err) + } + return out, nil +} + +func (d *DB) ArticleHasAuthor(ctx context.Context, articleID, userID int64) (bool, error) { + var found bool + if err := d.pool.QueryRow(ctx, qArticleHasAuthor, articleID, userID).Scan(&found); err != nil { + return false, fmt.Errorf("check author %d of article %d: %w", userID, articleID, err) + } + return found, nil +} diff --git a/internal/db/profile.go b/internal/db/profile.go new file mode 100644 index 00000000..e41d5cf9 --- /dev/null +++ b/internal/db/profile.go @@ -0,0 +1,82 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type Profile struct { + User + Bio string + FirstName string + LastName string + DateJoined time.Time +} + +const profileColumns = userColumns + `, + bio, first_name, last_name, date_joined` + +var qProfileByID = register("ProfileByID", ` +SELECT `+profileColumns+` +FROM web_user +WHERE id = $1`) + +func (d *DB) ProfileByID(ctx context.Context, id int64) (*Profile, error) { + return d.profile(ctx, qProfileByID, id) +} + +var qProfileByName = register("ProfileByName", ` +SELECT `+profileColumns+` +FROM web_user +WHERE username = $1 OR wikidot_username = $1 +LIMIT 1`) + +func (d *DB) ProfileByName(ctx context.Context, name string) (*Profile, error) { + return d.profile(ctx, qProfileByName, name) +} + +func (d *DB) profile(ctx context.Context, query string, arg any) (*Profile, error) { + var p Profile + dest, finish := userDest(&p.User) + dest = append(dest, &p.Bio, &p.FirstName, &p.LastName, &p.DateJoined) + + err := d.pool.QueryRow(ctx, query, arg).Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup profile %v: %w", arg, err) + } + finish() + return &p, nil +} + +// A nil avatar leaves the stored one alone, so saving the form without picking +// a file does not wipe the picture. +var qUpdateProfile = register("UpdateProfile", ` +UPDATE web_user +SET first_name = $2, last_name = $3, bio = $4, avatar = COALESCE($5, avatar), language = $6 +WHERE id = $1`) + +func (d *DB) UpdateProfile(ctx context.Context, id int64, firstName, lastName, bio string, avatar *string, language string) error { + if _, err := d.pool.Exec(ctx, qUpdateProfile, id, firstName, lastName, bio, avatar, language); err != nil { + return fmt.Errorf("update profile %d: %w", id, err) + } + return nil +} + +var qDirectMessageBlocked = register("DirectMessageBlocked", ` +SELECT EXISTS (SELECT 1 FROM web_directmessageblock + WHERE blocker_id = $1 AND blocked_id = $2)`) + +func (d *DB) DirectMessageBlocked(ctx context.Context, blockerID, blockedID int64) (bool, error) { + var blocked bool + if err := d.pool.QueryRow(ctx, qDirectMessageBlocked, blockerID, blockedID).Scan(&blocked); err != nil { + return false, fmt.Errorf("check block of %d by %d: %w", blockedID, blockerID, err) + } + return blocked, nil +} diff --git a/internal/db/rating.go b/internal/db/rating.go new file mode 100644 index 00000000..9ec35f52 --- /dev/null +++ b/internal/db/rating.go @@ -0,0 +1,109 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +var qSiteRatingMode = register("SiteRatingMode", ` +SELECT rating_mode +FROM web_settings +WHERE site_id = $1`) + +func (d *DB) SiteRatingMode(ctx context.Context, siteID int64) (string, error) { + var mode string + err := d.pool.QueryRow(ctx, qSiteRatingMode, siteID).Scan(&mode) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query rating mode of site %d: %w", siteID, err) + } + return mode, nil +} + +var qCategoryRatingMode = register("CategoryRatingMode", ` +SELECT s.rating_mode +FROM web_settings s +JOIN web_category c ON c.id = s.category_id +WHERE c.site_id = $1 AND c.name = $2`) + +// CategoryRatingMode reports ErrNotFound for a category that has no row of its +// own as well as for one that exists without settings; both fall back to the +// site. +func (d *DB) CategoryRatingMode(ctx context.Context, siteID int64, category string) (string, error) { + var mode string + err := d.pool.QueryRow(ctx, qCategoryRatingMode, siteID, category).Scan(&mode) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", fmt.Errorf("query rating mode of category %q: %w", category, err) + } + return mode, nil +} + +type VoteStats struct { + Sum float64 + Count int + GoodUpDown int + Average float64 + GoodStars int +} + +// Both rating modes are counted in one pass. Which pair of columns is read +// depends on a setting the caller resolves, and a second round trip to learn +// that first would cost more than the two unused counts. +var qVoteStats = register("VoteStats", ` +SELECT COALESCE(SUM(rate), 0), + COUNT(rate), + COUNT(rate) FILTER (WHERE rate = 1), + COALESCE(AVG(rate), 0), + COUNT(rate) FILTER (WHERE rate >= 3) +FROM web_vote +WHERE article_id = $1`) + +func (d *DB) VoteStats(ctx context.Context, articleID int64) (VoteStats, error) { + var s VoteStats + err := d.pool.QueryRow(ctx, qVoteStats, articleID).Scan( + &s.Sum, &s.Count, &s.GoodUpDown, &s.Average, &s.GoodStars) + if err != nil { + return VoteStats{}, fmt.Errorf("query votes of article %d: %w", articleID, err) + } + return s, nil +} + +var qHasVoted = register("HasVoted", ` +SELECT EXISTS( + SELECT 1 FROM web_vote + WHERE article_id = $1 AND user_id IS NOT DISTINCT FROM $2::bigint)`) + +func (d *DB) HasVoted(ctx context.Context, articleID int64, userID *int64) (bool, error) { + var voted bool + if err := d.pool.QueryRow(ctx, qHasVoted, articleID, userID).Scan(&voted); err != nil { + return false, fmt.Errorf("query vote of article %d: %w", articleID, err) + } + return voted, nil +} + +var qVoteByUser = register("VoteByUser", ` +SELECT rate +FROM web_vote +WHERE article_id = $1 AND user_id IS NOT DISTINCT FROM $2 +ORDER BY id DESC +LIMIT 1`) + +func (d *DB) VoteByUser(ctx context.Context, articleID int64, userID *int64) (float64, bool, error) { + var rate float64 + err := d.pool.QueryRow(ctx, qVoteByUser, articleID, userID).Scan(&rate) + if errors.Is(err, pgx.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("query vote on article %d: %w", articleID, err) + } + return rate, true, nil +} diff --git a/internal/db/report_admin.go b/internal/db/report_admin.go new file mode 100644 index 00000000..5fdc4569 --- /dev/null +++ b/internal/db/report_admin.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + ReportReviewed = "reviewed" + ReportDismissed = "dismissed" +) + +type ReportRow struct { + ID int64 + Reporter string + Reported string + Reason string + Messages string + Status string + AdminNotes string + CreatedAt time.Time + ReviewedAt *time.Time + ReviewedBy string +} + +const reportColumns = `r.id, coalesce(rep.username, ''), coalesce(tgt.username, ''), + r.reason, r.reported_messages::text, r.status, r.admin_notes, r.created_at, + r.reviewed_at, coalesce(rev.username, '')` + +const reportJoins = ` +FROM web_userreport r +LEFT JOIN web_user rep ON rep.id = r.reporter_id +LEFT JOIN web_user tgt ON tgt.id = r.reported_id +LEFT JOIN web_user rev ON rev.id = r.reviewed_by_id` + +var qAdminReports = register("AdminReports", ` +SELECT `+reportColumns+reportJoins+` +WHERE ($1 = '' OR r.status = $1) AND r.site_id = $4 +ORDER BY r.created_at DESC, r.id DESC +LIMIT $2 OFFSET $3`) + +var qAdminReportCount = register("AdminReportCount", ` +SELECT count(*) FROM web_userreport r WHERE ($1 = '' OR r.status = $1) AND r.site_id = $2`) + +func scanReport(row pgx.Row, r *ReportRow) error { + return row.Scan(&r.ID, &r.Reporter, &r.Reported, &r.Reason, &r.Messages, + &r.Status, &r.AdminNotes, &r.CreatedAt, &r.ReviewedAt, &r.ReviewedBy) +} + +func (d *DB) AdminReports(ctx context.Context, siteID int64, status string, limit, offset int) ([]ReportRow, int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAdminReportCount, status, siteID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count reports: %w", err) + } + rows, err := d.pool.Query(ctx, qAdminReports, status, limit, offset, siteID) + if err != nil { + return nil, 0, fmt.Errorf("list reports: %w", err) + } + defer rows.Close() + + var out []ReportRow + for rows.Next() { + var r ReportRow + if err := scanReport(rows, &r); err != nil { + return nil, 0, err + } + out = append(out, r) + } + return out, total, rows.Err() +} + +var qAdminReport = register("AdminReport", `SELECT `+reportColumns+reportJoins+` WHERE r.id = $1 AND r.site_id = $2`) + +func (d *DB) AdminReport(ctx context.Context, siteID, id int64) (ReportRow, error) { + var r ReportRow + err := scanReport(d.pool.QueryRow(ctx, qAdminReport, id, siteID), &r) + if errors.Is(err, pgx.ErrNoRows) { + return ReportRow{}, ErrNotFound + } + if err != nil { + return ReportRow{}, fmt.Errorf("read report %d: %w", id, err) + } + return r, nil +} + +var qReviewReport = register("ReviewReport", ` +UPDATE web_userreport SET status = $2, admin_notes = $3, reviewed_at = $4, reviewed_by_id = $5 +WHERE id = $1 AND site_id = $6`) + +func (d *DB) ReviewReport(ctx context.Context, siteID, id int64, status, notes string, by int64, at time.Time) error { + var reviewedAt *time.Time + var reviewer *int64 + if status != ReportPending { + reviewedAt, reviewer = &at, &by + } + if _, err := d.pool.Exec(ctx, qReviewReport, id, status, notes, reviewedAt, reviewer, siteID); err != nil { + return fmt.Errorf("review report %d: %w", id, err) + } + return nil +} diff --git a/internal/db/role.go b/internal/db/role.go new file mode 100644 index 00000000..622bfc2d --- /dev/null +++ b/internal/db/role.go @@ -0,0 +1,119 @@ +package db + +import ( + "context" + "fmt" + + "github.com/WikitTeam/ProjectWikit/internal/roles" +) + +var qRolesByUser = register("RolesByUser", ` +SELECT r.id, r.slug, r.name, r.short_name, r.category_id, r.index, + r.is_staff, r.group_votes, r.inline_visual_mode, r.profile_visual_mode, + r.color, r.icon, r.badge_text, r.badge_bg, r.badge_text_color, r.badge_show_border +FROM web_role r +JOIN web_user_roles ur ON ur.role_id = r.id +WHERE ur.user_id = $1 AND r.site_id = $2 +ORDER BY r.index, r.id`) + +// Ordered the way the name tail and showcase queries both consume it. The tie- +// break on id covers the rows whose index is not unique. +func (d *DB) RolesByUser(ctx context.Context, siteID, userID int64) ([]roles.Role, error) { + rows, err := d.pool.Query(ctx, qRolesByUser, userID, siteID) + if err != nil { + return nil, fmt.Errorf("list roles of user %d: %w", userID, err) + } + defer rows.Close() + + var out []roles.Role + for rows.Next() { + var role roles.Role + if err := rows.Scan( + &role.ID, &role.Slug, &role.Name, &role.ShortName, &role.CategoryID, &role.Index, + &role.IsStaff, &role.GroupVotes, &role.InlineVisualMode, &role.ProfileVisualMode, + &role.Color, &role.Icon, &role.BadgeText, &role.BadgeBg, &role.BadgeTextColor, + &role.BadgeShowBorder, + ); err != nil { + return nil, fmt.Errorf("scan role of user %d: %w", userID, err) + } + out = append(out, role) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list roles of user %d: %w", userID, err) + } + return out, nil +} + +var qRolesByUsers = register("RolesByUsers", ` +SELECT ur.user_id, r.id, r.slug, r.name, r.short_name, r.category_id, r.index, + r.is_staff, r.group_votes, r.inline_visual_mode, r.profile_visual_mode, + r.color, r.icon, r.badge_text, r.badge_bg, r.badge_text_color, r.badge_show_border +FROM web_role r +JOIN web_user_roles ur ON ur.role_id = r.id +WHERE ur.user_id = ANY($1) AND r.site_id = $2 +ORDER BY ur.user_id, r.index, r.id`) + +func (d *DB) RolesByUsers(ctx context.Context, siteID int64, userIDs []int64) (map[int64][]roles.Role, error) { + out := map[int64][]roles.Role{} + if len(userIDs) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qRolesByUsers, userIDs, siteID) + if err != nil { + return nil, fmt.Errorf("list roles of %d users: %w", len(userIDs), err) + } + defer rows.Close() + + for rows.Next() { + var userID int64 + var role roles.Role + if err := rows.Scan( + &userID, + &role.ID, &role.Slug, &role.Name, &role.ShortName, &role.CategoryID, &role.Index, + &role.IsStaff, &role.GroupVotes, &role.InlineVisualMode, &role.ProfileVisualMode, + &role.Color, &role.Icon, &role.BadgeText, &role.BadgeBg, &role.BadgeTextColor, + &role.BadgeShowBorder, + ); err != nil { + return nil, fmt.Errorf("scan role of user %d: %w", userID, err) + } + out[userID] = append(out[userID], role) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list roles of %d users: %w", len(userIDs), err) + } + return out, nil +} + +var qAllRoles = register("AllRoles", ` +SELECT id, slug, name FROM web_role WHERE site_id = $1 ORDER BY index, id`) + +type RoleChoice struct { + ID int64 + Slug string + Name string +} + +func (d *DB) AllRoles(ctx context.Context, siteID int64) ([]RoleChoice, error) { + rows, err := d.pool.Query(ctx, qAllRoles, siteID) + if err != nil { + return nil, fmt.Errorf("list roles: %w", err) + } + defer rows.Close() + + var out []RoleChoice + for rows.Next() { + var c RoleChoice + if err := rows.Scan(&c.ID, &c.Slug, &c.Name); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (c RoleChoice) Label() string { + if c.Name != "" { + return c.Name + } + return c.Slug +} diff --git a/internal/db/role_test.go b/internal/db/role_test.go new file mode 100644 index 00000000..0c87d01b --- /dev/null +++ b/internal/db/role_test.go @@ -0,0 +1,73 @@ +package db + +import ( + "context" + "testing" + "time" +) + +func TestRolesByUserWithoutRoles(t *testing.T) { + d := newTestDB(t) + + got, err := d.RolesByUser(context.Background(), seedSiteID(t, d), 1) + if err != nil { + t.Fatalf("RolesByUser(1) err = %v, want nil", err) + } + if len(got) != 0 { + t.Errorf("len(RolesByUser(1)) = %d, want 0", len(got)) + } +} + +func TestActiveAt(t *testing.T) { + now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC) + past := now.Add(-time.Hour) + future := now.Add(time.Hour) + + cases := []struct { + name string + user User + want bool + }{ + {"flag only", User{IsActive: true}, true}, + {"flag only, off", User{IsActive: false}, false}, + {"deadline passed overrides a false flag", User{IsActive: false, InactiveUntil: &past}, true}, + {"deadline ahead overrides a true flag", User{IsActive: true, InactiveUntil: &future}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.user.ActiveAt(now); got != c.want { + t.Errorf("ActiveAt() = %t, want %t", got, c.want) + } + }) + } +} + +func TestRolesByUserSkipsAnotherSite(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + here := seedSiteID(t, d) + + roleList, err := d.AllRoles(ctx, here) + if err != nil { + t.Fatalf("AllRoles() err = %v, want nil", err) + } + if len(roleList) == 0 { + t.Skip("the seed site has no roles to ask about") + } + + elsewhere, err := d.AllRoles(ctx, here+1000) + if err != nil { + t.Fatalf("AllRoles(unknown site) err = %v, want nil", err) + } + if len(elsewhere) != 0 { + t.Errorf("len(AllRoles(unknown site)) = %d, want 0", len(elsewhere)) + } + + bySlug, err := d.RoleIDsBySlug(ctx, here+1000, []string{"everyone", "registered"}) + if err != nil { + t.Fatalf("RoleIDsBySlug(unknown site) err = %v, want nil", err) + } + if len(bySlug) != 0 { + t.Errorf("len(RoleIDsBySlug(unknown site)) = %d, want 0", len(bySlug)) + } +} diff --git a/internal/db/role_write.go b/internal/db/role_write.go new file mode 100644 index 00000000..186447ad --- /dev/null +++ b/internal/db/role_write.go @@ -0,0 +1,312 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type RoleRow struct { + ID int64 + Slug string + Name string + ShortName string + CategoryID *int64 + Index int + IsStaff bool + GroupVotes bool + VotesTitle string + InlineVisualMode string + ProfileVisualMode string + Color string + Icon string + BadgeText string + BadgeBg string + BadgeTextColor string + BadgeShowBorder bool + + Allow []string + Deny []string + Users int +} + +const roleColumns = `id, slug, name, coalesce(short_name, ''), category_id, index, is_staff, + group_votes, coalesce(votes_title, ''), inline_visual_mode, profile_visual_mode, + coalesce(color, ''), coalesce(icon, ''), coalesce(badge_text, ''), coalesce(badge_bg, ''), + coalesce(badge_text_color, ''), badge_show_border` + +func scanRole(row pgx.Row, r *RoleRow) error { + return row.Scan(&r.ID, &r.Slug, &r.Name, &r.ShortName, &r.CategoryID, &r.Index, &r.IsStaff, + &r.GroupVotes, &r.VotesTitle, &r.InlineVisualMode, &r.ProfileVisualMode, + &r.Color, &r.Icon, &r.BadgeText, &r.BadgeBg, &r.BadgeTextColor, &r.BadgeShowBorder) +} + +var qAdminRoles = register("AdminRoles", ` +SELECT `+roleColumns+`, (SELECT count(*) FROM web_user_roles ur WHERE ur.role_id = web_role.id) +FROM web_role WHERE site_id = $1 ORDER BY index, id`) + +func (d *DB) AdminRoles(ctx context.Context, siteID int64) ([]RoleRow, error) { + rows, err := d.pool.Query(ctx, qAdminRoles, siteID) + if err != nil { + return nil, fmt.Errorf("list roles: %w", err) + } + defer rows.Close() + + var out []RoleRow + for rows.Next() { + var r RoleRow + err := rows.Scan(&r.ID, &r.Slug, &r.Name, &r.ShortName, &r.CategoryID, &r.Index, &r.IsStaff, + &r.GroupVotes, &r.VotesTitle, &r.InlineVisualMode, &r.ProfileVisualMode, + &r.Color, &r.Icon, &r.BadgeText, &r.BadgeBg, &r.BadgeTextColor, &r.BadgeShowBorder, &r.Users) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +var qAdminRole = register("AdminRole", `SELECT `+roleColumns+` FROM web_role WHERE id = $1 AND site_id = $2`) + +var qRoleGrants = register("RoleGrants", ` +SELECT p.codename, false FROM web_role_permissions rp +JOIN auth_permission p ON p.id = rp.permission_id WHERE rp.role_id = $1 +UNION ALL +SELECT p.codename, true FROM web_role_restrictions rr +JOIN auth_permission p ON p.id = rr.permission_id WHERE rr.role_id = $1`) + +func (d *DB) AdminRole(ctx context.Context, siteID, id int64) (RoleRow, error) { + var r RoleRow + err := scanRole(d.pool.QueryRow(ctx, qAdminRole, id, siteID), &r) + if errors.Is(err, pgx.ErrNoRows) { + return RoleRow{}, ErrNotFound + } + if err != nil { + return RoleRow{}, fmt.Errorf("read role %d: %w", id, err) + } + + rows, err := d.pool.Query(ctx, qRoleGrants, id) + if err != nil { + return RoleRow{}, fmt.Errorf("read the grants of role %d: %w", id, err) + } + defer rows.Close() + for rows.Next() { + var name string + var denied bool + if err := rows.Scan(&name, &denied); err != nil { + return RoleRow{}, err + } + if denied { + r.Deny = append(r.Deny, name) + } else { + r.Allow = append(r.Allow, name) + } + } + return r, rows.Err() +} + +var qPermissionCatalog = register("PermissionCatalog", ` +SELECT p.codename FROM auth_permission p +JOIN django_content_type c ON c.id = p.content_type_id +WHERE c.app_label = 'web' AND c.model = 'roles' +ORDER BY p.codename`) + +func (d *DB) PermissionCatalog(ctx context.Context) ([]string, error) { + rows, err := d.pool.Query(ctx, qPermissionCatalog) + if err != nil { + return nil, fmt.Errorf("read the permission catalog: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + return out, rows.Err() +} + +var ( + qInsertRole = register("InsertRole", ` +INSERT INTO web_role (slug, name, short_name, category_id, index, is_staff, group_votes, + votes_title, inline_visual_mode, profile_visual_mode, color, icon, badge_text, badge_bg, + badge_text_color, badge_show_border, site_id) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id`) + + qUpdateRole = register("UpdateRole", ` +UPDATE web_role SET slug=$2, name=$3, short_name=$4, category_id=$5, index=$6, is_staff=$7, + group_votes=$8, votes_title=$9, inline_visual_mode=$10, profile_visual_mode=$11, + color=$12, icon=$13, badge_text=$14, badge_bg=$15, badge_text_color=$16, badge_show_border=$17 +WHERE id=$1 AND site_id=$18`) + + qClearRolePermissions = register("ClearRolePermissions", `DELETE FROM web_role_permissions WHERE role_id = $1`) + qClearRoleRestrictions = register("ClearRoleRestrictions", `DELETE FROM web_role_restrictions WHERE role_id = $1`) + + qGrantRolePermission = register("GrantRolePermission", ` +INSERT INTO web_role_permissions (role_id, permission_id) +SELECT $1, p.id FROM auth_permission p +JOIN django_content_type c ON c.id = p.content_type_id +WHERE c.app_label = 'web' AND c.model = 'roles' AND p.codename = ANY($2)`) + + qRestrictRole = register("RestrictRole", ` +INSERT INTO web_role_restrictions (role_id, permission_id) +SELECT $1, p.id FROM auth_permission p +JOIN django_content_type c ON c.id = p.content_type_id +WHERE c.app_label = 'web' AND c.model = 'roles' AND p.codename = ANY($2)`) +) + +func (d *DB) SaveRole(ctx context.Context, siteID int64, r RoleRow, withGrants bool) (int64, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin saving role %q: %w", r.Slug, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + args := []any{r.Slug, r.Name, r.ShortName, r.CategoryID, r.Index, r.IsStaff, r.GroupVotes, + r.VotesTitle, r.InlineVisualMode, r.ProfileVisualMode, r.Color, r.Icon, + r.BadgeText, r.BadgeBg, r.BadgeTextColor, r.BadgeShowBorder} + if r.ID == 0 { + if err := tx.QueryRow(ctx, qInsertRole, append(args, siteID)...).Scan(&r.ID); err != nil { + return 0, fmt.Errorf("create role %q: %w", r.Slug, err) + } + } else if _, err := tx.Exec(ctx, qUpdateRole, append(append([]any{r.ID}, args...), siteID)...); err != nil { + return 0, fmt.Errorf("update role %d: %w", r.ID, err) + } + + if withGrants { + if _, err := tx.Exec(ctx, qClearRolePermissions, r.ID); err != nil { + return 0, err + } + if _, err := tx.Exec(ctx, qClearRoleRestrictions, r.ID); err != nil { + return 0, err + } + if len(r.Allow) > 0 { + if _, err := tx.Exec(ctx, qGrantRolePermission, r.ID, r.Allow); err != nil { + return 0, fmt.Errorf("grant to role %d: %w", r.ID, err) + } + } + if len(r.Deny) > 0 { + if _, err := tx.Exec(ctx, qRestrictRole, r.ID, r.Deny); err != nil { + return 0, fmt.Errorf("restrict role %d: %w", r.ID, err) + } + } + } + return r.ID, tx.Commit(ctx) +} + +var qDeleteRole = register("DeleteRole", `DELETE FROM web_role WHERE id = $1 AND site_id = $2`) + +var roleDependents = []string{ + register("DropRoleGrants", `DELETE FROM web_role_permissions WHERE role_id = $1`), + register("DropRoleRestrictions", `DELETE FROM web_role_restrictions WHERE role_id = $1`), + register("DropRoleHolders", `DELETE FROM web_user_roles WHERE role_id = $1`), + register("DropRoleOverrides", `DELETE FROM web_rolepermissionsoverride WHERE role_id = $1`), + register("ClearRoleVotes", `UPDATE web_vote SET role_id = NULL WHERE role_id = $1`), + register("ClearRoleTickets", `UPDATE web_userticket SET granted_role_id = NULL WHERE granted_role_id = $1`), + register("ClearRoleOnSite", `UPDATE web_site SET + default_role_id = CASE WHEN default_role_id = $1 THEN NULL ELSE default_role_id END, + verified_role_id = CASE WHEN verified_role_id = $1 THEN NULL ELSE verified_role_id END, + membership_password_role_id = CASE WHEN membership_password_role_id = $1 + THEN NULL ELSE membership_password_role_id END`), +} + +func (d *DB) DeleteRole(ctx context.Context, siteID, id int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin deleting role %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + for _, statement := range roleDependents { + if _, err := tx.Exec(ctx, statement, id); err != nil { + return fmt.Errorf("detach role %d: %w", id, err) + } + } + if _, err := tx.Exec(ctx, qDeleteRole, id, siteID); err != nil { + return fmt.Errorf("delete role %d: %w", id, err) + } + return tx.Commit(ctx) +} + +var qOperationIndex = register("OperationIndex", ` +SELECT coalesce(min(r.index), 2147483647) FROM web_role r +JOIN web_user_roles ur ON ur.role_id = r.id WHERE ur.user_id = $1 AND r.site_id = $2`) + +func (d *DB) OperationIndex(ctx context.Context, siteID, userID int64) (int, error) { + var index int + if err := d.pool.QueryRow(ctx, qOperationIndex, userID, siteID).Scan(&index); err != nil { + return 0, fmt.Errorf("read the rank of user %d: %w", userID, err) + } + return index, nil +} + +type RoleCategoryRow struct { + ID int64 + Name string + Roles int +} + +var qRoleCategories = register("RoleCategories", ` +SELECT c.id, c.name, (SELECT count(*) FROM web_role r WHERE r.category_id = c.id) +FROM web_rolecategory c WHERE c.site_id = $1 ORDER BY c.name, c.id`) + +func (d *DB) RoleCategories(ctx context.Context, siteID int64) ([]RoleCategoryRow, error) { + rows, err := d.pool.Query(ctx, qRoleCategories, siteID) + if err != nil { + return nil, fmt.Errorf("list role categories: %w", err) + } + defer rows.Close() + + var out []RoleCategoryRow + for rows.Next() { + var c RoleCategoryRow + if err := rows.Scan(&c.ID, &c.Name, &c.Roles); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +var ( + qInsertRoleCategory = register("InsertRoleCategory", `INSERT INTO web_rolecategory (name, site_id) VALUES ($1,$2) RETURNING id`) + qUpdateRoleCategory = register("UpdateRoleCategory", `UPDATE web_rolecategory SET name = $2 WHERE id = $1 AND site_id = $3`) + qDeleteRoleCategory = register("DeleteRoleCategory", `DELETE FROM web_rolecategory WHERE id = $1 AND site_id = $2`) +) + +func (d *DB) SaveRoleCategory(ctx context.Context, siteID int64, c RoleCategoryRow) error { + if c.ID == 0 { + var id int64 + if err := d.pool.QueryRow(ctx, qInsertRoleCategory, c.Name, siteID).Scan(&id); err != nil { + return fmt.Errorf("create role category %q: %w", c.Name, err) + } + return nil + } + if _, err := d.pool.Exec(ctx, qUpdateRoleCategory, c.ID, c.Name, siteID); err != nil { + return fmt.Errorf("update role category %d: %w", c.ID, err) + } + return nil +} + +var qDetachRoleCategory = register("DetachRoleCategory", `UPDATE web_role SET category_id = NULL WHERE category_id = $1`) + +func (d *DB) DeleteRoleCategory(ctx context.Context, siteID, id int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin deleting role category %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if _, err := tx.Exec(ctx, qDetachRoleCategory, id); err != nil { + return fmt.Errorf("detach role category %d: %w", id, err) + } + if _, err := tx.Exec(ctx, qDeleteRoleCategory, id, siteID); err != nil { + return fmt.Errorf("delete role category %d: %w", id, err) + } + return tx.Commit(ctx) +} diff --git a/internal/db/schema_test.go b/internal/db/schema_test.go new file mode 100644 index 00000000..7ef817b6 --- /dev/null +++ b/internal/db/schema_test.go @@ -0,0 +1,55 @@ +package db + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func newTestDB(t *testing.T) *DB { + t.Helper() + dsn := os.Getenv(EnvDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the database test", EnvDSN) + } + d, err := Open(context.Background(), dsn) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + t.Cleanup(d.Close) + return d +} + +// TestQueriesMatchSchema asks Postgres to parse and plan every registered +// statement. A column that Django renamed or dropped fails here instead of on +// a page load. +func TestQueriesMatchSchema(t *testing.T) { + dsn := os.Getenv(EnvDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the database test", EnvDSN) + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pgxpool.New() err = %v, want nil", err) + } + defer pool.Close() + + conn, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("Acquire() err = %v, want nil", err) + } + defer conn.Release() + + if len(queries) == 0 { + t.Fatal("len(queries) = 0, want every statement registered") + } + for _, q := range queries { + if _, err := conn.Conn().Prepare(ctx, q.name, q.sql); err != nil { + t.Errorf("Prepare(%s) err = %v, want nil", q.name, err) + } + } +} diff --git a/internal/db/search.go b/internal/db/search.go new file mode 100644 index 00000000..dab2f951 --- /dev/null +++ b/internal/db/search.go @@ -0,0 +1,337 @@ +package db + +import ( + "context" + "fmt" + "strings" + "time" +) + +type SearchFilter struct { + Words []string + Category string + AuthorID *int64 + Include [][]int64 + Exclude []int64 + From *time.Time + To *time.Time + Hidden []string + SiteID int64 +} + +func (f SearchFilter) where(b *listBuilder) string { + parts := []string{"si.article_id IS NOT NULL", "a.site_id = " + b.arg(f.SiteID)} + if len(f.Hidden) > 0 { + parts = append(parts, "NOT (a.category = ANY("+b.arg(f.Hidden)+"))") + } + if f.Category != "" { + parts = append(parts, "a.category = "+b.arg(strings.ToLower(f.Category))) + } + if f.AuthorID != nil { + parts = append(parts, "EXISTS (SELECT 1 FROM web_article_authors aa"+ + " WHERE aa.article_id = a.id AND aa.user_id = "+b.arg(*f.AuthorID)+")") + } + // One name can name several tags and a page needs only one of them. Naming + // two tags asks for a page carrying both. + for _, group := range f.Include { + parts = append(parts, "EXISTS (SELECT 1 FROM web_article_tags at"+ + " WHERE at.article_id = a.id AND at.tag_id = ANY("+b.arg(group)+"))") + } + if len(f.Exclude) > 0 { + parts = append(parts, "NOT EXISTS (SELECT 1 FROM web_article_tags at"+ + " WHERE at.article_id = a.id AND at.tag_id = ANY("+b.arg(f.Exclude)+"))") + } + // A page, its category or any of its tags can be kept out of the search + // from the admin. A category with no row of its own counts as indexed. + parts = append(parts, "a.is_indexed") + parts = append(parts, "NOT EXISTS (SELECT 1 FROM web_category c"+ + " WHERE c.site_id = a.site_id AND c.name = a.category AND NOT c.is_indexed)") + parts = append(parts, "NOT EXISTS (SELECT 1 FROM web_article_tags ax"+ + " JOIN web_tag t ON t.id = ax.tag_id"+ + " WHERE ax.article_id = a.id AND NOT t.is_indexed)") + if f.From != nil { + parts = append(parts, "a.created_at >= "+b.arg(*f.From)) + } + if f.To != nil { + parts = append(parts, "a.created_at <= "+b.arg(*f.To)) + } + for _, word := range f.Words { + parts = append(parts, "si.content_plaintext ILIKE "+b.arg(likeContains(word))) + } + return "FROM web_articlesearchindex si\nJOIN web_article a ON a.id = si.article_id\nWHERE " + + strings.Join(parts, "\n AND ") +} + +type SearchHit struct { + Article Article + Plaintext string +} + +func (f SearchFilter) SelectSQL(offset, limit int) (string, []any) { + b := &listBuilder{} + return f.selectSQL(b, offset, limit), b.args +} + +func (f SearchFilter) selectSQL(b *listBuilder, offset, limit int) string { + return "SELECT " + prefixedArticleColumns + ", si.content_plaintext\n" + + f.where(b) + "\nORDER BY a.created_at DESC, a.id DESC" + + "\nLIMIT " + b.arg(limit) + "\nOFFSET " + b.arg(offset) +} + +func (d *DB) SearchArticles(ctx context.Context, f SearchFilter, offset, limit int) ([]SearchHit, error) { + b := &listBuilder{} + sql := f.selectSQL(b, offset, limit) + + rows, err := d.pool.Query(ctx, sql, b.args...) + if err != nil { + return nil, fmt.Errorf("query search: %w", err) + } + defer rows.Close() + + var out []SearchHit + for rows.Next() { + var hit SearchHit + a := &hit.Article + if err := rows.Scan(&a.ID, &a.Category, &a.Name, &a.Title, &a.ParentID, &a.Locked, + &a.CreatedAt, &a.UpdatedAt, &a.MediaName, &hit.Plaintext); err != nil { + return nil, fmt.Errorf("scan search hit: %w", err) + } + out = append(out, hit) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read search: %w", err) + } + return out, nil +} + +func (f SearchFilter) CountSQL() (string, []any) { + b := &listBuilder{} + return "SELECT COUNT(*)\n" + f.where(b), b.args +} + +func (d *DB) SearchCount(ctx context.Context, f SearchFilter) (int, error) { + sql, args := f.CountSQL() + + var n int + if err := d.pool.QueryRow(ctx, sql, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("count search: %w", err) + } + return n, nil +} + +var qTagIDsByFullName = register("TagIDsByFullName", ` +SELECT t.id +FROM web_tag t +JOIN web_tagscategory c ON c.id = t.category_id +WHERE t.name = $1 AND ($2 = '' OR c.slug = $2) AND t.site_id = $3`) + +// A name without a category matches that tag in every category, which is what +// makes one name able to name several tags. +func (d *DB) TagIDsByFullName(ctx context.Context, siteID int64, category, name string) ([]int64, error) { + rows, err := d.pool.Query(ctx, qTagIDsByFullName, name, category, siteID) + if err != nil { + return nil, fmt.Errorf("query tag %q: %w", name, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan tag id: %w", err) + } + out = append(out, id) + } + return out, rows.Err() +} + +var qAuthorsOfArticles = register("AuthorsOfArticles", ` +SELECT link.article_id, `+prefixed("u", userColumns)+` +FROM web_article_authors link +JOIN web_user u ON u.id = link.user_id +WHERE link.article_id = ANY($1) +ORDER BY link.article_id, link.id`) + +func (d *DB) AuthorsOfArticles(ctx context.Context, ids []int64) (map[int64][]User, error) { + out := make(map[int64][]User, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qAuthorsOfArticles, ids) + if err != nil { + return nil, fmt.Errorf("query authors: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + var u User + dest, finish := userDest(&u) + if err := rows.Scan(append([]any{&id}, dest...)...); err != nil { + return nil, fmt.Errorf("scan author: %w", err) + } + finish() + out[id] = append(out[id], u) + } + return out, rows.Err() +} + +var qVoteStatsOfArticles = register("VoteStatsOfArticles", ` +SELECT article_id, + COALESCE(SUM(rate), 0), + COUNT(rate), + COUNT(rate) FILTER (WHERE rate = 1), + COALESCE(AVG(rate), 0), + COUNT(rate) FILTER (WHERE rate >= 3) +FROM web_vote +WHERE article_id = ANY($1) +GROUP BY article_id`) + +func (d *DB) VoteStatsOfArticles(ctx context.Context, ids []int64) (map[int64]VoteStats, error) { + out := make(map[int64]VoteStats, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qVoteStatsOfArticles, ids) + if err != nil { + return nil, fmt.Errorf("query votes: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + var s VoteStats + if err := rows.Scan(&id, &s.Sum, &s.Count, &s.GoodUpDown, &s.Average, &s.GoodStars); err != nil { + return nil, fmt.Errorf("scan votes: %w", err) + } + out[id] = s + } + return out, rows.Err() +} + +var qCommentCountsOfArticles = register("CommentCountsOfArticles", ` +SELECT t.article_id, COUNT(p.id) +FROM web_forumthread t +JOIN web_forumpost p ON p.thread_id = t.id +WHERE t.article_id = ANY($1) +GROUP BY t.article_id`) + +func (d *DB) CommentCountsOfArticles(ctx context.Context, ids []int64) (map[int64]int, error) { + out := make(map[int64]int, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qCommentCountsOfArticles, ids) + if err != nil { + return nil, fmt.Errorf("query comment counts: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + var n int + if err := rows.Scan(&id, &n); err != nil { + return nil, fmt.Errorf("scan comment count: %w", err) + } + out[id] = n + } + return out, rows.Err() +} + +var qTagsOfArticles = register("TagsOfArticles", ` +SELECT link.article_id, t.id, c.slug, t.name +FROM web_article_tags link +JOIN web_tag t ON t.id = link.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE link.article_id = ANY($1) +ORDER BY link.article_id, c.slug, t.name`) + +type ArticleTag struct { + ID int64 + Category string + Name string +} + +func (t ArticleTag) FullName() string { + if t.Category == DefaultCategory || t.Category == "" { + return t.Name + } + return t.Category + ":" + t.Name +} + +func (d *DB) TagsOfArticles(ctx context.Context, ids []int64) (map[int64][]ArticleTag, error) { + out := make(map[int64][]ArticleTag, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qTagsOfArticles, ids) + if err != nil { + return nil, fmt.Errorf("query tags: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + var t ArticleTag + if err := rows.Scan(&id, &t.ID, &t.Category, &t.Name); err != nil { + return nil, fmt.Errorf("scan tag: %w", err) + } + out[id] = append(out[id], t) + } + return out, rows.Err() +} + +var qLatestEditorsOfArticles = register("LatestEditorsOfArticles", ` +SELECT DISTINCT ON (e.article_id) e.article_id, `+prefixed("u", userColumns)+` +FROM web_articlelogentry e +JOIN web_user u ON u.id = e.user_id +WHERE e.article_id = ANY($1) +ORDER BY e.article_id, e.rev_number DESC`) + +func (d *DB) LatestEditorsOfArticles(ctx context.Context, ids []int64) (map[int64]User, error) { + out := make(map[int64]User, len(ids)) + if len(ids) == 0 { + return out, nil + } + rows, err := d.pool.Query(ctx, qLatestEditorsOfArticles, ids) + if err != nil { + return nil, fmt.Errorf("query latest editors: %w", err) + } + defer rows.Close() + + for rows.Next() { + var id int64 + var u User + dest, finish := userDest(&u) + if err := rows.Scan(append([]any{&id}, dest...)...); err != nil { + return nil, fmt.Errorf("scan latest editor: %w", err) + } + finish() + out[id] = u + } + return out, rows.Err() +} + +var qCategoryRatingModes = register("CategoryRatingModes", ` +SELECT c.name, s.rating_mode +FROM web_settings s +JOIN web_category c ON c.id = s.category_id +WHERE c.site_id = $1`) + +func (d *DB) CategoryRatingModes(ctx context.Context, siteID int64) (map[string]string, error) { + rows, err := d.pool.Query(ctx, qCategoryRatingModes, siteID) + if err != nil { + return nil, fmt.Errorf("query category rating modes: %w", err) + } + defer rows.Close() + + out := map[string]string{} + for rows.Next() { + var name, mode string + if err := rows.Scan(&name, &mode); err != nil { + return nil, fmt.Errorf("scan category rating mode: %w", err) + } + out[name] = mode + } + return out, rows.Err() +} diff --git a/internal/db/search_exclude_test.go b/internal/db/search_exclude_test.go new file mode 100644 index 00000000..4f275719 --- /dev/null +++ b/internal/db/search_exclude_test.go @@ -0,0 +1,52 @@ +package db + +import ( + "context" + "strings" + "testing" +) + +func TestSearchWhereKeepsUnindexedContentOut(t *testing.T) { + b := &listBuilder{} + got := SearchFilter{SiteID: 1}.where(b) + for _, want := range []string{ + "a.is_indexed", + "FROM web_category c", + "NOT c.is_indexed", + "JOIN web_tag t ON t.id = ax.tag_id", + "NOT t.is_indexed", + } { + if !strings.Contains(got, want) { + t.Errorf("Contains(where(), %q) = false, want true", want) + } + } +} + +func TestSetArticleIndexedGoesBothWays(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + article := scratchArticle(t, d) + site := seedSiteID(t, d) + + for _, want := range []bool{false, true} { + if err := d.SetArticleIndexed(ctx, site, article, want); err != nil { + t.Fatalf("SetArticleIndexed(%t) err = %v, want nil", want, err) + } + var got bool + err := d.pool.QueryRow(ctx, `SELECT is_indexed FROM web_article WHERE id = $1`, article).Scan(&got) + if err != nil { + t.Fatalf("read is_indexed err = %v, want nil", err) + } + if got != want { + t.Errorf("is_indexed = %t, want %t", got, want) + } + } +} + +func TestSetArticleIndexedIsNotFoundForAnotherSite(t *testing.T) { + d := writeTestDB(t) + article := scratchArticle(t, d) + if err := d.SetArticleIndexed(context.Background(), -1, article, false); err == nil { + t.Error("SetArticleIndexed() err = nil, want ErrNotFound") + } +} diff --git a/internal/db/search_write.go b/internal/db/search_write.go new file mode 100644 index 00000000..5f9f5b0f --- /dev/null +++ b/internal/db/search_write.go @@ -0,0 +1,36 @@ +package db + +import ( + "context" + "fmt" +) + +var ( + // Two configurations are stacked because a page can hold either language and + // the column is the only place the search reads from. + qUpdateSearchIndex = register("UpdateSearchIndex", ` +UPDATE web_articlesearchindex +SET content_source = $2, + content_plaintext = $3, + vector_plaintext = to_tsvector('english', $3) || to_tsvector('russian', $3) +WHERE article_id = $1`) + + qInsertSearchIndex = register("InsertSearchIndex", ` +INSERT INTO web_articlesearchindex (article_id, content_source, content_plaintext, vector_plaintext) +SELECT $1, $2, $3, to_tsvector('english', $3) || to_tsvector('russian', $3) +WHERE NOT EXISTS (SELECT 1 FROM web_articlesearchindex WHERE article_id = $1)`) +) + +func (d *DB) UpdateSearchIndex(ctx context.Context, articleID int64, source, plaintext string) error { + tag, err := d.pool.Exec(ctx, qUpdateSearchIndex, articleID, source, plaintext) + if err != nil { + return fmt.Errorf("update search index of %d: %w", articleID, err) + } + if tag.RowsAffected() > 0 { + return nil + } + if _, err := d.pool.Exec(ctx, qInsertSearchIndex, articleID, source, plaintext); err != nil { + return fmt.Errorf("write search index of %d: %w", articleID, err) + } + return nil +} diff --git a/internal/db/session.go b/internal/db/session.go new file mode 100644 index 00000000..64a37452 --- /dev/null +++ b/internal/db/session.go @@ -0,0 +1,52 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +var qSessionByKey = register("SessionByKey", ` +SELECT session_data, expire_date +FROM django_session +WHERE session_key = $1 AND expire_date > now()`) + +func (d *DB) SessionByKey(ctx context.Context, key string) (string, time.Time, error) { + var ( + data string + expires time.Time + ) + err := d.pool.QueryRow(ctx, qSessionByKey, key).Scan(&data, &expires) + if errors.Is(err, pgx.ErrNoRows) { + return "", time.Time{}, ErrNotFound + } + if err != nil { + return "", time.Time{}, fmt.Errorf("lookup session: %w", err) + } + return data, expires, nil +} + +var qSaveSession = register("SaveSession", ` +INSERT INTO django_session (session_key, session_data, expire_date) +VALUES ($1, $2, $3) +ON CONFLICT (session_key) DO UPDATE +SET session_data = EXCLUDED.session_data, expire_date = EXCLUDED.expire_date`) + +func (d *DB) SaveSession(ctx context.Context, key, data string, expires time.Time) error { + if _, err := d.pool.Exec(ctx, qSaveSession, key, data, expires); err != nil { + return fmt.Errorf("save session: %w", err) + } + return nil +} + +var qDeleteSession = register("DeleteSession", `DELETE FROM django_session WHERE session_key = $1`) + +func (d *DB) DeleteSession(ctx context.Context, key string) error { + if _, err := d.pool.Exec(ctx, qDeleteSession, key); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} diff --git a/internal/db/session_test.go b/internal/db/session_test.go new file mode 100644 index 00000000..9ab3cdfa --- /dev/null +++ b/internal/db/session_test.go @@ -0,0 +1,88 @@ +package db + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSessionRoundTrip(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + key := "gotestsessionkey00000000000000aa" + t.Cleanup(func() { d.DeleteSession(ctx, key) }) + + if err := d.SaveSession(ctx, key, "payload", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("SaveSession() err = %v, want nil", err) + } + data, expires, err := d.SessionByKey(ctx, key) + if err != nil { + t.Fatalf("SessionByKey() err = %v, want nil", err) + } + if data != "payload" { + t.Errorf("SessionByKey() data = %q, want %q", data, "payload") + } + if expires.Before(time.Now()) { + t.Errorf("SessionByKey() expires = %v, want a future time", expires) + } +} + +func TestSaveSessionOverwrites(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + key := "gotestsessionkey00000000000000bb" + t.Cleanup(func() { d.DeleteSession(ctx, key) }) + + if err := d.SaveSession(ctx, key, "first", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("SaveSession(first) err = %v, want nil", err) + } + if err := d.SaveSession(ctx, key, "second", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("SaveSession(second) err = %v, want nil", err) + } + data, _, err := d.SessionByKey(ctx, key) + if err != nil { + t.Fatalf("SessionByKey() err = %v, want nil", err) + } + if data != "second" { + t.Errorf("SessionByKey() data = %q, want %q", data, "second") + } +} + +func TestSessionByKeyTreatsExpiredAsMissing(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + key := "gotestsessionkey00000000000000cc" + t.Cleanup(func() { d.DeleteSession(ctx, key) }) + + if err := d.SaveSession(ctx, key, "payload", time.Now().Add(-time.Hour)); err != nil { + t.Fatalf("SaveSession() err = %v, want nil", err) + } + if _, _, err := d.SessionByKey(ctx, key); !errors.Is(err, ErrNotFound) { + t.Errorf("SessionByKey(expired) err = %v, want ErrNotFound", err) + } +} + +func TestSessionByKeyUnknown(t *testing.T) { + d := newTestDB(t) + + if _, _, err := d.SessionByKey(context.Background(), "gotestsessionkey0000000000000zz"); !errors.Is(err, ErrNotFound) { + t.Errorf("SessionByKey(unknown) err = %v, want ErrNotFound", err) + } +} + +func TestDeleteSession(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + key := "gotestsessionkey00000000000000dd" + + if err := d.SaveSession(ctx, key, "payload", time.Now().Add(time.Hour)); err != nil { + t.Fatalf("SaveSession() err = %v, want nil", err) + } + if err := d.DeleteSession(ctx, key); err != nil { + t.Fatalf("DeleteSession() err = %v, want nil", err) + } + if _, _, err := d.SessionByKey(ctx, key); !errors.Is(err, ErrNotFound) { + t.Errorf("SessionByKey(deleted) err = %v, want ErrNotFound", err) + } +} diff --git a/internal/db/site.go b/internal/db/site.go new file mode 100644 index 00000000..6d0ad49d --- /dev/null +++ b/internal/db/site.go @@ -0,0 +1,159 @@ +package db + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type Site struct { + ID int64 + Slug string + Title string + Headline string + Domain string + MediaDomain string + HomePage string + Icon string + ThemeID *int64 + + SystemThemeID *int64 + + AuthIcon string + FooterLicense string + SignupNotice string + PasswordHelp string + + MembershipPasswordEnabled bool + MembershipPassword string + MembershipPasswordRoleID *int64 + + DefaultRoleID *int64 + VerifiedRoleID *int64 + + EmailPolicy string + Language string + TimeZone string +} + +var qSiteByHost = register("SiteByHost", ` +SELECT id, slug, title, headline, domain, media_domain, home_page, COALESCE(icon, ''), active_theme_id, + system_theme_id, COALESCE(auth_icon, ''), footer_license, signup_notice, password_help, + membership_password_enabled, membership_password, membership_password_role_id, + default_role_id, verified_role_id, email_policy, language, time_zone +FROM web_site +WHERE lower(domain) = $1 OR lower(media_domain) = $1 +ORDER BY id +LIMIT 1`) + +func scanSite(row pgx.Row, s *Site) error { + return row.Scan( + &s.ID, &s.Slug, &s.Title, &s.Headline, &s.Domain, &s.MediaDomain, &s.HomePage, + &s.Icon, &s.ThemeID, &s.SystemThemeID, + &s.AuthIcon, &s.FooterLicense, &s.SignupNotice, &s.PasswordHelp, + &s.MembershipPasswordEnabled, &s.MembershipPassword, &s.MembershipPasswordRoleID, + &s.DefaultRoleID, &s.VerifiedRoleID, &s.EmailPolicy, &s.Language, &s.TimeZone) +} + +var qSiteBySlug = register("SiteBySlug", ` +SELECT id, slug, title, headline, domain, media_domain, home_page, COALESCE(icon, ''), active_theme_id, + system_theme_id, COALESCE(auth_icon, ''), footer_license, signup_notice, password_help, + membership_password_enabled, membership_password, membership_password_role_id, + default_role_id, verified_role_id, email_policy, language, time_zone +FROM web_site +WHERE slug = $1`) + +var qSiteSlugs = register("SiteSlugs", `SELECT slug FROM web_site ORDER BY id`) + +func (d *DB) SiteBySlug(ctx context.Context, slug string) (*Site, error) { + var s Site + err := scanSite(d.pool.QueryRow(ctx, qSiteBySlug, slug), &s) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("look up site %q: %w", slug, err) + } + return &s, nil +} + +func (d *DB) SiteSlugs(ctx context.Context) ([]string, error) { + rows, err := d.pool.Query(ctx, qSiteSlugs) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "42P01" { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("list sites: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var slug string + if err := rows.Scan(&slug); err != nil { + return nil, err + } + out = append(out, slug) + } + return out, rows.Err() +} + +// SiteByHosts tries each host in turn and returns the first that matches. +// Callers pass site.LookupHosts, whose ordering carries the host:port round +// that has to run before the bare-host one. +func (d *DB) SiteByHosts(ctx context.Context, hosts []string) (*Site, error) { + for _, host := range hosts { + var s Site + err := scanSite(d.pool.QueryRow(ctx, qSiteByHost, strings.ToLower(host)), &s) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return nil, fmt.Errorf("lookup site by host %q: %w", host, err) + } + return &s, nil + } + return nil, ErrNotFound +} + +var qSiteHosts = register("SiteHosts", ` +SELECT domain FROM web_site UNION SELECT media_domain FROM web_site ORDER BY 1`) + +func (d *DB) SiteHosts(ctx context.Context) ([]string, error) { + rows, err := d.pool.Query(ctx, qSiteHosts) + if err != nil { + return nil, fmt.Errorf("list site hosts: %w", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var host string + if err := rows.Scan(&host); err != nil { + return nil, err + } + out = append(out, host) + } + return out, rows.Err() +} + +var qSiteHostExists = register("SiteHostExists", ` +SELECT EXISTS(SELECT 1 FROM web_site WHERE lower(domain) = $1 OR lower(media_domain) = $1)`) + +func (d *DB) SiteHostExists(ctx context.Context, host string) (bool, error) { + var exists bool + if err := d.pool.QueryRow(ctx, qSiteHostExists, strings.ToLower(host)).Scan(&exists); err != nil { + return false, fmt.Errorf("check host %q belongs to a site: %w", host, err) + } + return exists, nil +} + +const ( + EmailAtSignup = "at_signup" + EmailRequired = "required" + EmailOptional = "optional" +) diff --git a/internal/db/site_scope_pending_test.go b/internal/db/site_scope_pending_test.go new file mode 100644 index 00000000..7ae5e90b --- /dev/null +++ b/internal/db/site_scope_pending_test.go @@ -0,0 +1,56 @@ +package db + +var siteScopePending = []string{} + +var siteScopeByKey = []string{ + "ActivateInviteLink", + "AdminCategoryOverrides", + "ArticleChildren", + "Breadcrumbs", + "ClearRoleTickets", + "DeleteArticle.13", + "DeleteArticle.14", + "DetachRoleCategory", + "DetachTagCat", + "FindTag", + "KnownTags", + "MoveArticleParent", + "ReadArticleTitle", + "RenameArticle", + "SetArticleLock", + "SetArticleParent", + "SetArticleTitle", + "ThemeByID", + "TouchArticle", + "TouchForumThread", + "UpdateArticleTitle", + "ForumCategory", + "ForumSection", + "ForumThread", + "ForumThreadsByStart", + "ForumThreadsByReply", + "ForumThreadsInCategories", + "ForumCategoryCounts", + "ForumCategoryLastPost", + "RecentPosts", + "RecentPostCount", + "UserPosts", + "UserPostCount", + "ChildCount", + "CommentCount", + "ArticleLastComment", + "CommentInfo", + "ArticleTags", + "ArticleTagCategories", + "ArticleTagNames", + "ReadArticleTags", + "TagsOfArticles", + "CommentCountsOfArticles", + "ArticleVotes", + "UpdateForumThread", + "DeleteArticle.0", + "DeleteArticle.1", + "DeleteArticle.2", + "DeleteArticle.3", + "DeleteArticle.4", +} diff --git a/internal/db/site_scope_test.go b/internal/db/site_scope_test.go new file mode 100644 index 00000000..fa8ca03e --- /dev/null +++ b/internal/db/site_scope_test.go @@ -0,0 +1,125 @@ +package db + +import ( + "os" + "regexp" + "slices" + "sort" + "strings" + "testing" +) + +var perSiteTables = []string{ + "web_article", + "web_category", + "web_tag", + "web_tagscategory", + "web_role", + "web_rolecategory", + "web_forumsection", + "web_forumcategory", + "web_forumthread", + "web_theme", + "web_invitelink", + "web_userreport", + "web_userticket", + "pwikit_admin_log", + "web_externallink", +} + +func touchedTables(sql string) []string { + var out []string + for _, table := range perSiteTables { + pattern := regexp.MustCompile(`\b` + table + `\b`) + if pattern.MatchString(sql) { + out = append(out, table) + } + } + return out +} + +func TestEveryPerSiteQueryNamesTheSite(t *testing.T) { + pending := map[string]bool{} + for _, name := range siteScopePending { + pending[name] = true + } + + byKey := map[string]bool{} + for _, name := range siteScopeByKey { + byKey[name] = true + } + + var missing, stale, both []string + for _, q := range queries { + if pending[q.name] && byKey[q.name] { + both = append(both, q.name) + } + settled := len(touchedTables(q.sql)) == 0 || + strings.Contains(q.sql, "site_id") || + byKey[q.name] + if settled { + if pending[q.name] { + stale = append(stale, q.name) + } + continue + } + if !pending[q.name] { + missing = append(missing, q.name) + } + } + + for _, name := range both { + t.Errorf("query %q is in both siteScopeByKey and siteScopePending, want one", name) + } + + for _, name := range missing { + t.Errorf("query %q reads a per-site table without site_id, want the filter or an entry in siteScopePending", name) + } + for _, name := range stale { + t.Errorf("query %q is in siteScopePending but no longer needs to be, want it removed", name) + } + + if os.Getenv("PWIKIT_SITE_SCOPE_LIST") != "" { + var left []string + for _, q := range queries { + if pending[q.name] { + left = append(left, q.name) + } + } + sort.Strings(left) + t.Logf("%d queries still to scope:\n%s", len(left), strings.Join(left, "\n")) + } +} + +func TestSiteScopeListsHoldOnlyKnownNames(t *testing.T) { + known := make([]string, 0, len(queries)) + for _, q := range queries { + known = append(known, q.name) + } + for list, names := range map[string][]string{ + "siteScopePending": siteScopePending, + "siteScopeByKey": siteScopeByKey, + } { + for _, name := range names { + if !slices.Contains(known, name) { + t.Errorf("%s has %q, want a registered query name", list, name) + } + } + } +} + +func TestBuiltFiltersNameTheSite(t *testing.T) { + list, _ := ListFilter{SiteID: 7}.SelectSQL(0, nil) + if !strings.Contains(list, "a.site_id") { + t.Errorf("ListFilter.SelectSQL() = %q, want it to name a.site_id", list) + } + changes, _ := SiteChangeFilter{SiteID: 7}.SelectSQL(0, 10) + if !strings.Contains(changes, "a.site_id") { + t.Errorf("SiteChangeFilter.SelectSQL() = %q, want it to name a.site_id", changes) + } + b := &listBuilder{} + search := SearchFilter{SiteID: 7}.where(b) + if !strings.Contains(search, "a.site_id") { + t.Errorf("SearchFilter.where() = %q, want it to name a.site_id", search) + } +} diff --git a/internal/db/site_scope_write_test.go b/internal/db/site_scope_write_test.go new file mode 100644 index 00000000..1f3ddb91 --- /dev/null +++ b/internal/db/site_scope_write_test.go @@ -0,0 +1,238 @@ +package db + +import ( + "context" + "errors" + "strconv" + "testing" + "time" +) + +func scratchSite(t *testing.T, d *DB) int64 { + t.Helper() + ctx := context.Background() + stamp := strconv.FormatInt(time.Now().UnixNano(), 36) + id, err := d.CreateSite(ctx, NewSite{ + Slug: "probe-" + stamp, Title: "Probe", Headline: "Probe", + Domain: "probe-" + stamp + ".test", MediaDomain: "probe-" + stamp + ".test", + }) + if err != nil { + t.Fatalf("CreateSite() err = %v, want nil", err) + } + t.Cleanup(func() { + ctx := context.Background() + for _, sql := range []string{ + `DELETE FROM web_user_roles WHERE role_id IN (SELECT id FROM web_role WHERE site_id = $1)`, + `DELETE FROM web_role_permissions WHERE role_id IN (SELECT id FROM web_role WHERE site_id = $1)`, + `DELETE FROM web_role WHERE site_id = $1`, + `DELETE FROM web_settings WHERE site_id = $1`, + `DELETE FROM pwikit_admin_log WHERE site_id = $1`, + `DELETE FROM web_userreport WHERE site_id = $1`, + `DELETE FROM web_site WHERE id = $1`, + } { + if _, err := d.pool.Exec(ctx, sql, id); err != nil { + t.Errorf("clean up site %d err = %v, want nil", id, err) + } + } + }) + return id +} + +func scratchRole(t *testing.T, d *DB, siteID int64) int64 { + t.Helper() + var id int64 + slug := "probe-role-" + strconv.FormatInt(time.Now().UnixNano(), 36) + if err := d.pool.QueryRow(context.Background(), qInsertBuiltInRole, siteID, slug, 99).Scan(&id); err != nil { + t.Fatalf("insert role err = %v, want nil", err) + } + return id +} + +func heldRoles(t *testing.T, d *DB, userID int64) map[int64]bool { + t.Helper() + rows, err := d.pool.Query(context.Background(), `SELECT role_id FROM web_user_roles WHERE user_id = $1`, userID) + if err != nil { + t.Fatalf("read roles err = %v, want nil", err) + } + defer rows.Close() + held := map[int64]bool{} + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + held[id] = true + } + return held +} + +func TestSaveAdminUserLeavesRolesOnOtherSites(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + here, there := scratchSite(t, d), scratchSite(t, d) + roleHere, roleThere := scratchRole(t, d, here), scratchRole(t, d, there) + + userID, err := d.CreateUser(ctx, scratchName(t), "Probe Roles", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, userID) + for site, role := range map[int64]int64{here: roleHere, there: roleThere} { + if err := d.GrantRole(ctx, site, userID, role); err != nil { + t.Fatalf("GrantRole(%d) err = %v, want nil", role, err) + } + } + + row, err := d.AdminUser(ctx, here, userID) + if err != nil { + t.Fatalf("AdminUser() err = %v, want nil", err) + } + if len(row.Roles) != 1 || row.Roles[0] != roleHere { + t.Errorf("AdminUser(here).Roles = %v, want [%d]", row.Roles, roleHere) + } + + row.Roles = nil + if err := d.SaveAdminUser(ctx, here, row, []string{"registered", "everyone"}, true, false); err != nil { + t.Fatalf("SaveAdminUser() err = %v, want nil", err) + } + held := heldRoles(t, d, userID) + if held[roleHere] { + t.Errorf("role %d on this site after clearing = held, want removed", roleHere) + } + if !held[roleThere] { + t.Errorf("role %d on the other site after clearing here = removed, want held", roleThere) + } +} + +func TestReviewReportStoresTheDecision(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + site := scratchSite(t, d) + var id int64 + err := d.pool.QueryRow(ctx, ` +INSERT INTO web_userreport (reason, reported_messages, status, admin_notes, created_at, site_id) +VALUES ('probe', '[]', $1, '', now(), $2) RETURNING id`, ReportPending, site).Scan(&id) + if err != nil { + t.Fatalf("insert report err = %v, want nil", err) + } + + userID, err := d.CreateUser(ctx, scratchName(t), "Probe Reviewer", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, userID) + t.Cleanup(func() { + d.pool.Exec(context.Background(), `DELETE FROM web_userreport WHERE id = $1`, id) + }) + + if err := d.ReviewReport(ctx, site, id, ReportReviewed, "handled", userID, time.Now().UTC()); err != nil { + t.Fatalf("ReviewReport() err = %v, want nil", err) + } + got, err := d.AdminReport(ctx, site, id) + if err != nil { + t.Fatalf("AdminReport() err = %v, want nil", err) + } + if got.Status != ReportReviewed { + t.Errorf("AdminReport().Status = %q, want %q", got.Status, ReportReviewed) + } + if got.AdminNotes != "handled" { + t.Errorf("AdminReport().AdminNotes = %q, want %q", got.AdminNotes, "handled") + } +} + +func TestUserByVerifiedEmailSkipsAnUnverifiedAddress(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + email := scratchName(t) + "@example.invalid" + + typo, err := d.CreateUser(ctx, scratchName(t)+"-a", "Probe Typo", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, typo) + if err := d.SetEmail(ctx, typo, email); err != nil { + t.Fatalf("SetEmail() err = %v, want nil", err) + } + + if _, err := d.UserByVerifiedEmail(ctx, email); !errors.Is(err, ErrNotFound) { + t.Errorf("UserByVerifiedEmail() with only an unverified holder err = %v, want ErrNotFound", err) + } + + owner, err := d.CreateUser(ctx, scratchName(t)+"-b", "Probe Owner", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, owner) + if err := d.SetEmail(ctx, owner, email); err != nil { + t.Fatalf("SetEmail() err = %v, want nil", err) + } + if ok, err := d.MarkEmailVerified(ctx, owner, email, time.Now().UTC()); err != nil || !ok { + t.Fatalf("MarkEmailVerified() = %t, %v, want true, nil", ok, err) + } + + got, err := d.UserByVerifiedEmail(ctx, email) + if err != nil { + t.Fatalf("UserByVerifiedEmail() err = %v, want nil", err) + } + if got.ID != owner { + t.Errorf("UserByVerifiedEmail().ID = %d, want %d", got.ID, owner) + } +} + +func TestResetUserVotesLeavesOtherSitesAlone(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + here, there := scratchSite(t, d), scratchSite(t, d) + + userID, err := d.CreateUser(ctx, scratchName(t), "Probe Voter", "!", true, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateUser() err = %v, want nil", err) + } + dropUser(t, d, userID) + + rated := map[int64]int64{} + for _, site := range []int64{here, there} { + name := "probe-vote-" + strconv.FormatInt(time.Now().UnixNano(), 36) + article, err := d.CreateArticle(ctx, site, "_default", name, name, nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + rated[site] = article + t.Cleanup(func() { + clean := context.Background() + for _, sql := range []string{ + `DELETE FROM web_vote WHERE article_id = $1`, + `DELETE FROM web_articlelogentry WHERE article_id = $1`, + `DELETE FROM web_articleversion WHERE article_id = $1`, + `DELETE FROM web_article_authors WHERE article_id = $1`, + `DELETE FROM web_article WHERE id = $1`, + } { + if _, err := d.pool.Exec(clean, sql, article); err != nil { + t.Errorf("clean up article %d err = %v, want nil", article, err) + } + } + }) + if _, err := d.pool.Exec(ctx, ` +INSERT INTO web_vote (rate, article_id, user_id, date) VALUES (1, $1, $2, now())`, article, userID); err != nil { + t.Fatalf("insert vote err = %v, want nil", err) + } + } + + removed, err := d.ResetUserVotes(ctx, here, userID) + if err != nil { + t.Fatalf("ResetUserVotes() err = %v, want nil", err) + } + if removed != 1 { + t.Errorf("ResetUserVotes(here) = %d, want 1", removed) + } + for site, want := range map[int64]int{here: 0, there: 1} { + var left int + if err := d.pool.QueryRow(ctx, ` +SELECT count(*) FROM web_vote WHERE article_id = $1 AND user_id = $2`, rated[site], userID).Scan(&left); err != nil { + t.Fatal(err) + } + if left != want { + t.Errorf("votes left on site %d = %d, want %d", site, left, want) + } + } +} diff --git a/internal/db/site_settings.go b/internal/db/site_settings.go new file mode 100644 index 00000000..fb3d1fd7 --- /dev/null +++ b/internal/db/site_settings.go @@ -0,0 +1,95 @@ +package db + +import ( + "context" + "fmt" +) + +type SiteSettings struct { + RatingMode string + CreateTags string +} + +var qSiteSettingsRow = register("SiteSettingsRow", ` +SELECT rating_mode, can_user_create_tags +FROM web_settings WHERE site_id = $1 AND category_id IS NULL`) + +func (d *DB) SiteSettings(ctx context.Context, siteID int64) (SiteSettings, error) { + var s SiteSettings + rows, err := d.pool.Query(ctx, qSiteSettingsRow, siteID) + if err != nil { + return s, fmt.Errorf("read settings of site %d: %w", siteID, err) + } + defer rows.Close() + if rows.Next() { + if err := rows.Scan(&s.RatingMode, &s.CreateTags); err != nil { + return s, err + } + } + return s, rows.Err() +} + +var qUpdateSiteSettings = register("UpdateSiteSettings", ` +UPDATE web_settings SET rating_mode = $2, can_user_create_tags = $3 +WHERE site_id = $1 AND category_id IS NULL`) + +var qUpdateSite = register("UpdateSite", ` +UPDATE web_site SET + slug = $2, title = $3, headline = $4, domain = $5, media_domain = $6, home_page = $7, + active_theme_id = $8, system_theme_id = $9, icon = $10, auth_icon = $11, + footer_license = $12, signup_notice = $13, password_help = $14, email_policy = $15, + language = $16, time_zone = $17 +WHERE id = $1`) + +var qUpdateSiteRoles = register("UpdateSiteRoles", ` +UPDATE web_site SET + default_role_id = $2, verified_role_id = $3, + membership_password_enabled = $4, membership_password = $5, membership_password_role_id = $6 +WHERE id = $1`) + +func (d *DB) SaveSite(ctx context.Context, s *Site, settings SiteSettings, withRoles bool) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin saving site %d: %w", s.ID, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + _, err = tx.Exec(ctx, qUpdateSite, s.ID, s.Slug, s.Title, s.Headline, s.Domain, s.MediaDomain, + s.HomePage, s.ThemeID, s.SystemThemeID, nullable(s.Icon), nullable(s.AuthIcon), + s.FooterLicense, s.SignupNotice, s.PasswordHelp, s.EmailPolicy, s.Language, s.TimeZone) + if err != nil { + return fmt.Errorf("save site %d: %w", s.ID, err) + } + if withRoles { + _, err = tx.Exec(ctx, qUpdateSiteRoles, s.ID, s.DefaultRoleID, s.VerifiedRoleID, + s.MembershipPasswordEnabled, s.MembershipPassword, s.MembershipPasswordRoleID) + if err != nil { + return fmt.Errorf("save the roles of site %d: %w", s.ID, err) + } + } + if _, err := tx.Exec(ctx, qUpdateSiteSettings, s.ID, settings.RatingMode, settings.CreateTags); err != nil { + return fmt.Errorf("save the settings of site %d: %w", s.ID, err) + } + return tx.Commit(ctx) +} + +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} + +var qSetSiteHosts = register("SetSiteHosts", ` +UPDATE web_site SET domain = $2, media_domain = $3 WHERE slug = $1`) + +func (d *DB) SetSiteHosts(ctx context.Context, slug, domain, mediaDomain string) error { + tag, err := d.pool.Exec(ctx, qSetSiteHosts, slug, domain, mediaDomain) + if err != nil { + return fmt.Errorf("rebind site %q: %w", slug, err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/internal/db/site_test.go b/internal/db/site_test.go new file mode 100644 index 00000000..78e3d534 --- /dev/null +++ b/internal/db/site_test.go @@ -0,0 +1,81 @@ +package db + +import ( + "context" + "errors" + "testing" +) + +func TestSiteByHostsMatchesDomainAndMediaDomain(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + byDomain, err := d.SiteByHosts(ctx, []string{"localhost"}) + if err != nil { + t.Fatalf("SiteByHosts([localhost]) err = %v, want nil", err) + } + if byDomain.Slug != "wikit" { + t.Errorf("SiteByHosts([localhost]).Slug = %q, want %q", byDomain.Slug, "wikit") + } + + byMedia, err := d.SiteByHosts(ctx, []string{"media.localhost"}) + if err != nil { + t.Fatalf("SiteByHosts([media.localhost]) err = %v, want nil", err) + } + if byMedia.ID != byDomain.ID { + t.Errorf("SiteByHosts([media.localhost]).ID = %d, want %d", byMedia.ID, byDomain.ID) + } +} + +func TestSiteByHostsTriesHostsInOrder(t *testing.T) { + d := newTestDB(t) + + got, err := d.SiteByHosts(context.Background(), []string{"localhost:8000", "localhost"}) + if err != nil { + t.Fatalf("SiteByHosts() err = %v, want nil", err) + } + if got.Domain != "localhost" { + t.Errorf("SiteByHosts().Domain = %q, want %q", got.Domain, "localhost") + } +} + +func TestSiteByHostsUnknownHost(t *testing.T) { + d := newTestDB(t) + + _, err := d.SiteByHosts(context.Background(), []string{"nope.example"}) + if !errors.Is(err, ErrNotFound) { + t.Errorf("SiteByHosts([nope.example]) err = %v, want ErrNotFound", err) + } +} + +func TestSiteHostExistsAcceptsBothDomainsAndRejectsStrangers(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + for _, host := range []string{"localhost", "media.localhost", "LOCALHOST"} { + got, err := d.SiteHostExists(ctx, host) + if err != nil { + t.Fatalf("SiteHostExists(%q) err = %v, want nil", host, err) + } + if !got { + t.Errorf("SiteHostExists(%q) = false, want true", host) + } + } + + got, err := d.SiteHostExists(ctx, "someone-elses-domain.example") + if err != nil { + t.Fatalf("SiteHostExists() err = %v, want nil", err) + } + if got { + t.Error("SiteHostExists(\"someone-elses-domain.example\") = true, want false") + } +} + +func seedSiteID(t *testing.T, d *DB) int64 { + t.Helper() + found, err := d.SiteByHosts(context.Background(), []string{"localhost"}) + if err != nil { + t.Fatalf("SiteByHosts(localhost) err = %v, want nil", err) + } + return found.ID +} diff --git a/internal/db/site_write.go b/internal/db/site_write.go new file mode 100644 index 00000000..cf6bd613 --- /dev/null +++ b/internal/db/site_write.go @@ -0,0 +1,87 @@ +package db + +import ( + "context" + "fmt" + + "github.com/WikitTeam/ProjectWikit/internal/perms" +) + +type NewSite struct { + Slug string + Title string + Headline string + Domain string + MediaDomain string +} + +var qInsertSite = register("InsertSite", ` +INSERT INTO web_site (slug, title, headline, domain, media_domain, home_page, + footer_license, signup_notice, password_help, email_policy, + membership_password, membership_password_enabled) +VALUES ($1, $2, $3, $4, $5, 'main', '', '', '', $6, '', false) +RETURNING id`) + +var qInsertSiteSettings = register("InsertSiteSettings", ` +INSERT INTO web_settings (site_id, category_id, rating_mode, can_user_create_tags) +VALUES ($1, NULL, 'updown', 'disabled')`) + +var qInsertBuiltInRole = register("InsertBuiltInRole", ` +INSERT INTO web_role (site_id, slug, index, name, short_name, is_staff, group_votes, + votes_title, inline_visual_mode, profile_visual_mode, color, + icon, badge_text, badge_bg, badge_text_color, badge_show_border) +VALUES ($1, $2, $3, '', '', false, false, '', 'hidden', 'hidden', '#000000', + '', '', '#808080', '#ffffff', false) +RETURNING id`) + +// The permission resolver looks both of these up by slug, so a site without +// them answers every question with no rights at all. +var builtInRoles = []struct { + slug string + index int + permissions []string +}{ + {slug: "registered", index: 1}, + {slug: "everyone", index: 2, permissions: []string{ + perms.ViewArticles, + perms.ViewArticleComments, + perms.ViewForumSections, + perms.ViewForumCategories, + perms.ViewForumThreads, + perms.ViewForumPosts, + }}, +} + +func (d *DB) CreateSite(ctx context.Context, s NewSite) (int64, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return 0, fmt.Errorf("begin creating site %q: %w", s.Slug, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + var id int64 + err = tx.QueryRow(ctx, qInsertSite, s.Slug, s.Title, s.Headline, s.Domain, s.MediaDomain, EmailOptional).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create site %q: %w", s.Slug, err) + } + if _, err := tx.Exec(ctx, qInsertSiteSettings, id); err != nil { + return 0, fmt.Errorf("create settings for site %q: %w", s.Slug, err) + } + for _, built := range builtInRoles { + var roleID int64 + err := tx.QueryRow(ctx, qInsertBuiltInRole, id, built.slug, built.index).Scan(&roleID) + if err != nil { + return 0, fmt.Errorf("create role %q for site %q: %w", built.slug, s.Slug, err) + } + if len(built.permissions) == 0 { + continue + } + if _, err := tx.Exec(ctx, qGrantRolePermission, roleID, built.permissions); err != nil { + return 0, fmt.Errorf("grant %q on site %q: %w", built.slug, s.Slug, err) + } + } + if err := tx.Commit(ctx); err != nil { + return 0, fmt.Errorf("commit site %q: %w", s.Slug, err) + } + return id, nil +} diff --git a/internal/db/sitechanges.go b/internal/db/sitechanges.go new file mode 100644 index 00000000..f6564afd --- /dev/null +++ b/internal/db/sitechanges.go @@ -0,0 +1,222 @@ +package db + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +type SiteChangeFilter struct { + SiteID int64 + + Hidden []string + + Types []string + + Category string + HasCategory bool + + HasUser bool + UserIDs []int64 + WithSystem bool +} + +type SiteChange struct { + RevNumber int + Type string + Meta []byte + Comment string + CreatedAt time.Time + UserID *int64 + + ArticleTitle string + ArticleCategory string + ArticleName string +} + +// A revert carries the types it undid in meta rather than in the type column, +// so asking for one type has to reach both places. +func (f SiteChangeFilter) build(b *listBuilder) string { + b.where = append(b.where, "a.site_id = "+b.arg(f.SiteID)) + if len(f.Hidden) > 0 { + b.where = append(b.where, "NOT (a.category = ANY("+b.arg(f.Hidden)+"))") + } + if len(f.Types) > 0 { + ors := []string{"e.type = ANY(" + b.arg(f.Types) + ")"} + for _, t := range f.Types { + ors = append(ors, "(e.meta -> 'subtypes') @> "+b.arg(strconv.Quote(t))+"::jsonb") + } + b.where = append(b.where, "("+strings.Join(ors, " OR ")+")") + } + if f.HasCategory { + b.where = append(b.where, "a.category = "+b.arg(f.Category)) + } + if f.HasUser { + or := "e.user_id = ANY(" + b.arg(f.UserIDs) + ")" + if f.WithSystem { + or += " OR e.user_id IS NULL" + } + b.where = append(b.where, "("+or+")") + } + + sql := "FROM web_articlelogentry e\nJOIN web_article a ON a.id = e.article_id" + if len(b.where) > 0 { + sql += "\nWHERE " + strings.Join(b.where, "\n AND ") + } + return sql +} + +func (f SiteChangeFilter) selectSQL(b *listBuilder, offset, limit int) string { + sql := `SELECT e.rev_number, e.type, e.meta, e.comment, e.created_at, e.user_id, + a.title, a.category, a.name +` + f.build(b) + "\nORDER BY e.created_at DESC" + + "\nLIMIT " + b.arg(limit) + "\nOFFSET " + b.arg(offset) + return sql +} + +// Exposed so the schema-drift test can send a built statement to Postgres the +// way it sends the ones written out by hand. +func (f SiteChangeFilter) SelectSQL(offset, limit int) (string, []any) { + b := &listBuilder{} + return f.selectSQL(b, offset, limit), b.args +} + +func (d *DB) SiteChanges(ctx context.Context, f SiteChangeFilter, offset, limit int) ([]SiteChange, error) { + b := &listBuilder{} + sql := f.selectSQL(b, offset, limit) + + rows, err := d.pool.Query(ctx, sql, b.args...) + if err != nil { + return nil, fmt.Errorf("query site changes: %w", err) + } + defer rows.Close() + + var out []SiteChange + for rows.Next() { + var c SiteChange + if err := rows.Scan(&c.RevNumber, &c.Type, &c.Meta, &c.Comment, &c.CreatedAt, + &c.UserID, &c.ArticleTitle, &c.ArticleCategory, &c.ArticleName); err != nil { + return nil, fmt.Errorf("scan site change: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read site changes: %w", err) + } + return out, nil +} + +func (d *DB) SiteChangeCount(ctx context.Context, f SiteChangeFilter) (int, error) { + b := &listBuilder{} + sql := "SELECT COUNT(*)\n" + f.build(b) + + var n int + if err := d.pool.QueryRow(ctx, sql, b.args...).Scan(&n); err != nil { + return 0, fmt.Errorf("count site changes: %w", err) + } + return n, nil +} + +var qArticleCategories = register("ArticleCategories", ` +SELECT DISTINCT category +FROM web_article +WHERE NOT (category = ANY($1)) AND site_id = $2`) + +func (d *DB) ArticleCategories(ctx context.Context, siteID int64, hidden []string) ([]string, error) { + if hidden == nil { + hidden = []string{} + } + rows, err := d.pool.Query(ctx, qArticleCategories, hidden, siteID) + if err != nil { + return nil, fmt.Errorf("query article categories: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan article category: %w", err) + } + out = append(out, name) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read article categories: %w", err) + } + return out, nil +} + +var qUserIDsByName = register("UserIDsByName", ` +SELECT id +FROM web_user +WHERE username = $1 OR wikidot_username = $1`) + +// Uppercasing both sides rather than leaning on citext, so a column changed to +// text would not silently start matching case-sensitively. +var qUserIDsByNamePart = register("UserIDsByNamePart", ` +SELECT id +FROM web_user +WHERE UPPER(username::text) LIKE UPPER($1) OR UPPER(wikidot_username::text) LIKE UPPER($1)`) + +func (d *DB) UserIDsByName(ctx context.Context, name string, partial bool) ([]int64, error) { + sql, arg := qUserIDsByName, name + if partial { + sql, arg = qUserIDsByNamePart, likeContains(name) + } + rows, err := d.pool.Query(ctx, sql, arg) + if err != nil { + return nil, fmt.Errorf("query users named %q: %w", name, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan user named %q: %w", name, err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read users named %q: %w", name, err) + } + return out, nil +} + +var qUsersByIDs = register("UsersByIDs", ` +SELECT `+userColumns+` +FROM web_user +WHERE id = ANY($1)`) + +func (d *DB) UsersByIDs(ctx context.Context, ids []int64) ([]User, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := d.pool.Query(ctx, qUsersByIDs, ids) + if err != nil { + return nil, fmt.Errorf("query users by id: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + dest, finish := userDest(&u) + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan user by id: %w", err) + } + finish() + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read users by id: %w", err) + } + return out, nil +} + +func likeContains(s string) string { + r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return "%" + r.Replace(s) + "%" +} diff --git a/internal/db/sitechanges_test.go b/internal/db/sitechanges_test.go new file mode 100644 index 00000000..f5a76fa9 --- /dev/null +++ b/internal/db/sitechanges_test.go @@ -0,0 +1,61 @@ +package db + +import ( + "context" + "strings" + "testing" +) + +func siteChangeFilterVariants() map[string]SiteChangeFilter { + return map[string]SiteChangeFilter{ + "empty": {}, + "hidden": {Hidden: []string{"admin"}}, + "one-type": {Types: []string{"source"}}, + "many-types": {Types: []string{"source", "title", "revert"}}, + "category": {Category: "probe", HasCategory: true}, + "users": {HasUser: true, UserIDs: int64s(1, 2)}, + "users-none": {HasUser: true}, + "system": {HasUser: true, UserIDs: int64s(1), WithSystem: true}, + "everything": {Hidden: []string{"admin"}, Types: []string{"tags"}, Category: "probe", HasCategory: true, HasUser: true, UserIDs: int64s(1), WithSystem: true}, + } +} + +func TestSiteChangeFilterSQLMatchesSchema(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + for name, filter := range siteChangeFilterVariants() { + if _, err := d.SiteChanges(ctx, filter, 5, 20); err != nil { + t.Errorf("SiteChanges(%s) err = %v, want nil", name, err) + } + if _, err := d.SiteChangeCount(ctx, filter); err != nil { + t.Errorf("SiteChangeCount(%s) err = %v, want nil", name, err) + } + } +} + +func TestSiteChangeSelectSQLNamesEveryArgument(t *testing.T) { + for name, filter := range siteChangeFilterVariants() { + sql, args := filter.SelectSQL(3, 10) + for i := range args { + if !strings.Contains(sql, placeholder(i+1)) { + t.Errorf("SelectSQL(%s) leaves %s unused", name, placeholder(i+1)) + } + } + } +} + +func TestLikeContainsEscapesWildcards(t *testing.T) { + cases := map[string]string{ + "probe": "%probe%", + "a_b": `%a\_b%`, + "a%b": `%a\%b%`, + `a\b`: `%a\\b%`, + "": "%%", + } + for in, want := range cases { + if got := likeContains(in); got != want { + t.Errorf("likeContains(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/db/tag.go b/internal/db/tag.go new file mode 100644 index 00000000..0c03b765 --- /dev/null +++ b/internal/db/tag.go @@ -0,0 +1,186 @@ +package db + +import ( + "context" + "fmt" +) + +var qTagIDsByName = register("TagIDsByName", ` +SELECT t.id +FROM web_tag t +JOIN web_tagscategory c ON c.id = t.category_id +WHERE t.site_id = $1 AND c.slug = $2 AND t.name = $3 +ORDER BY t.id`) + +var qTagIDsByBareName = register("TagIDsByBareName", ` +SELECT id +FROM web_tag +WHERE site_id = $1 AND name = $2 +ORDER BY id`) + +func (d *DB) TagIDsByName(ctx context.Context, siteID int64, categorySlug, name string) ([]int64, error) { + sql, args := qTagIDsByBareName, []any{siteID, name} + if categorySlug != "" { + sql, args = qTagIDsByName, []any{siteID, categorySlug, name} + } + rows, err := d.pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query tag %q: %w", name, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan tag %q: %w", name, err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read tag %q: %w", name, err) + } + return out, nil +} + +var qArticleTagIDs = register("ArticleTagIDs", ` +SELECT tag_id +FROM web_article_tags +WHERE article_id = $1 +ORDER BY tag_id`) + +func (d *DB) ArticleTagIDs(ctx context.Context, articleID int64) ([]int64, error) { + rows, err := d.pool.Query(ctx, qArticleTagIDs, articleID) + if err != nil { + return nil, fmt.Errorf("query tags of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan tag of article %d: %w", articleID, err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read tags of article %d: %w", articleID, err) + } + return out, nil +} + +type CloudTag struct { + Name string + FullName string + Articles int + CategoryID int64 + CategoryName string + CategorySlug string + CategoryText string + Priority *int +} + +var qTagCloud = register("TagCloud", ` +SELECT t.name, + CASE WHEN c.slug = '_default' THEN t.name ELSE c.slug || ':' || t.name END, + count(link.article_id), + c.id, c.name, c.slug, c.description, c.priority +FROM web_tag t +JOIN web_tagscategory c ON c.id = t.category_id +LEFT JOIN web_article_tags link ON link.tag_id = t.id +WHERE t.site_id = $1 AND t.name NOT LIKE '\_%' +GROUP BY t.id, t.name, c.id, c.name, c.slug, c.description, c.priority +ORDER BY count(link.article_id) DESC`) + +// The limit lands after the busiest tags come first, so it decides which tags +// are in the cloud and not just how many. +func (d *DB) TagCloud(ctx context.Context, siteID int64, limit *int) ([]CloudTag, error) { + sql := qTagCloud + args := []any{siteID} + if limit != nil { + sql += "\nLIMIT $2" + args = append(args, *limit) + } + rows, err := d.pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query tag cloud: %w", err) + } + defer rows.Close() + + var out []CloudTag + for rows.Next() { + var t CloudTag + if err := rows.Scan(&t.Name, &t.FullName, &t.Articles, + &t.CategoryID, &t.CategoryName, &t.CategorySlug, &t.CategoryText, &t.Priority); err != nil { + return nil, fmt.Errorf("scan tag cloud: %w", err) + } + out = append(out, t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read tag cloud: %w", err) + } + return out, nil +} + +type TagsCategory struct { + ID int64 + Name string + Description string + Slug string +} + +var qTagsCategories = register("TagsCategories", ` +SELECT id, name, description, slug +FROM web_tagscategory +WHERE site_id = $1 +ORDER BY priority, id`) + +func (d *DB) TagsCategories(ctx context.Context, siteID int64) ([]TagsCategory, error) { + rows, err := d.pool.Query(ctx, qTagsCategories, siteID) + if err != nil { + return nil, fmt.Errorf("list tag categories: %w", err) + } + defer rows.Close() + + var out []TagsCategory + for rows.Next() { + var c TagsCategory + if err := rows.Scan(&c.ID, &c.Name, &c.Description, &c.Slug); err != nil { + return nil, fmt.Errorf("scan tag category: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list tag categories: %w", err) + } + return out, nil +} + +type NamedTag struct { + CategoryID int64 + Name string +} + +var qAllTags = register("AllTags", `SELECT category_id, name FROM web_tag WHERE site_id = $1 ORDER BY id`) + +func (d *DB) AllTags(ctx context.Context, siteID int64) ([]NamedTag, error) { + rows, err := d.pool.Query(ctx, qAllTags, siteID) + if err != nil { + return nil, fmt.Errorf("list tags: %w", err) + } + defer rows.Close() + + var out []NamedTag + for rows.Next() { + var t NamedTag + if err := rows.Scan(&t.CategoryID, &t.Name); err != nil { + return nil, fmt.Errorf("scan tag: %w", err) + } + out = append(out, t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list tags: %w", err) + } + return out, nil +} diff --git a/internal/db/tag_admin.go b/internal/db/tag_admin.go new file mode 100644 index 00000000..4c028eeb --- /dev/null +++ b/internal/db/tag_admin.go @@ -0,0 +1,191 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" +) + +type TagCategoryRow struct { + ID int64 + Name string + Slug string + Description string + Priority *int + Tags int +} + +var qAdminTagCategories = register("AdminTagCategories", ` +SELECT c.id, c.name, c.slug, c.description, c.priority, + (SELECT count(*) FROM web_tag t WHERE t.category_id = c.id) +FROM web_tagscategory c WHERE c.site_id = $1 ORDER BY c.priority DESC, c.name, c.id`) + +func (d *DB) AdminTagCategories(ctx context.Context, siteID int64) ([]TagCategoryRow, error) { + rows, err := d.pool.Query(ctx, qAdminTagCategories, siteID) + if err != nil { + return nil, fmt.Errorf("list tag categories: %w", err) + } + defer rows.Close() + + var out []TagCategoryRow + for rows.Next() { + var c TagCategoryRow + if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.Priority, &c.Tags); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +var qAdminTagCategory = register("AdminTagCategory", ` +SELECT id, name, slug, description, priority FROM web_tagscategory WHERE id = $1 AND site_id = $2`) + +func (d *DB) AdminTagCategory(ctx context.Context, siteID, id int64) (TagCategoryRow, error) { + var c TagCategoryRow + err := d.pool.QueryRow(ctx, qAdminTagCategory, id, siteID). + Scan(&c.ID, &c.Name, &c.Slug, &c.Description, &c.Priority) + if errors.Is(err, pgx.ErrNoRows) { + return TagCategoryRow{}, ErrNotFound + } + if err != nil { + return TagCategoryRow{}, fmt.Errorf("read tag category %d: %w", id, err) + } + return c, nil +} + +var ( + qInsertTagCat = register("InsertTagCat", ` +INSERT INTO web_tagscategory (name, slug, description, priority, site_id) VALUES ($1,$2,$3,$4,$5) RETURNING id`) + qUpdateTagCat = register("UpdateTagCat", ` +UPDATE web_tagscategory SET name=$2, slug=$3, description=$4, priority=$5 WHERE id=$1 AND site_id=$6`) + qDetachTagCat = register("DetachTagCat", `UPDATE web_tag SET category_id = NULL WHERE category_id = $1`) + qDeleteTagCat = register("DeleteTagCat", `DELETE FROM web_tagscategory WHERE id = $1 AND site_id = $2`) +) + +func (d *DB) SaveTagCategory(ctx context.Context, siteID int64, c TagCategoryRow) error { + if c.ID == 0 { + var id int64 + err := d.pool.QueryRow(ctx, qInsertTagCat, c.Name, c.Slug, c.Description, c.Priority, siteID).Scan(&id) + if err != nil { + return fmt.Errorf("create tag category %q: %w", c.Slug, err) + } + return nil + } + _, err := d.pool.Exec(ctx, qUpdateTagCat, c.ID, c.Name, c.Slug, c.Description, c.Priority, siteID) + if err != nil { + return fmt.Errorf("update tag category %d: %w", c.ID, err) + } + return nil +} + +func (d *DB) DeleteTagCategory(ctx context.Context, siteID, id int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin deleting tag category %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if _, err := tx.Exec(ctx, qDetachTagCat, id); err != nil { + return fmt.Errorf("detach tag category %d: %w", id, err) + } + if _, err := tx.Exec(ctx, qDeleteTagCat, id, siteID); err != nil { + return fmt.Errorf("delete tag category %d: %w", id, err) + } + return tx.Commit(ctx) +} + +type TagRow struct { + ID int64 + Name string + CategoryID *int64 + CategoryName string + Articles int + IsIndexed bool +} + +var qAdminTags = register("AdminTags", ` +SELECT t.id, t.name, t.category_id, coalesce(c.name, ''), + (SELECT count(*) FROM web_article_tags at WHERE at.tag_id = t.id) +FROM web_tag t LEFT JOIN web_tagscategory c ON c.id = t.category_id +WHERE ($1 = '' OR t.name ILIKE '%' || $1 || '%') AND t.site_id = $4 +ORDER BY c.name NULLS FIRST, t.name +LIMIT $2 OFFSET $3`) + +var qAdminTagCount = register("AdminTagCount", ` +SELECT count(*) FROM web_tag t WHERE ($1 = '' OR t.name ILIKE '%' || $1 || '%') AND t.site_id = $2`) + +func (d *DB) AdminTags(ctx context.Context, siteID int64, query string, limit, offset int) ([]TagRow, int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAdminTagCount, query, siteID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count tags: %w", err) + } + rows, err := d.pool.Query(ctx, qAdminTags, query, limit, offset, siteID) + if err != nil { + return nil, 0, fmt.Errorf("list tags: %w", err) + } + defer rows.Close() + + var out []TagRow + for rows.Next() { + var t TagRow + if err := rows.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Articles); err != nil { + return nil, 0, err + } + out = append(out, t) + } + return out, total, rows.Err() +} + +var qAdminTag = register("AdminTag", `SELECT id, name, category_id, is_indexed FROM web_tag WHERE id = $1 AND site_id = $2`) + +func (d *DB) AdminTag(ctx context.Context, siteID, id int64) (TagRow, error) { + var t TagRow + err := d.pool.QueryRow(ctx, qAdminTag, id, siteID).Scan(&t.ID, &t.Name, &t.CategoryID, &t.IsIndexed) + if errors.Is(err, pgx.ErrNoRows) { + return TagRow{}, ErrNotFound + } + if err != nil { + return TagRow{}, fmt.Errorf("read tag %d: %w", id, err) + } + return t, nil +} + +var ( + qInsertTagRow = register("InsertTagRow", `INSERT INTO web_tag (name, category_id, site_id, is_indexed) VALUES ($1,$2,$3,$4) RETURNING id`) + qUpdateTag = register("UpdateTag", `UPDATE web_tag SET name=$2, category_id=$3, is_indexed=$5 WHERE id=$1 AND site_id=$4`) + qDetachTag = register("DetachTag", `DELETE FROM web_article_tags WHERE tag_id = $1`) + qDeleteTagRow = register("DeleteTagRow", `DELETE FROM web_tag WHERE id = $1 AND site_id = $2`) +) + +func (d *DB) SaveTag(ctx context.Context, siteID int64, t TagRow) error { + if t.ID == 0 { + var id int64 + if err := d.pool.QueryRow(ctx, qInsertTagRow, t.Name, t.CategoryID, siteID, t.IsIndexed).Scan(&id); err != nil { + return fmt.Errorf("create tag %q: %w", t.Name, err) + } + return nil + } + if _, err := d.pool.Exec(ctx, qUpdateTag, t.ID, t.Name, t.CategoryID, siteID, t.IsIndexed); err != nil { + return fmt.Errorf("update tag %d: %w", t.ID, err) + } + return nil +} + +func (d *DB) DeleteTag(ctx context.Context, siteID, id int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin deleting tag %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if _, err := tx.Exec(ctx, qDetachTag, id); err != nil { + return fmt.Errorf("detach tag %d: %w", id, err) + } + if _, err := tx.Exec(ctx, qDeleteTagRow, id, siteID); err != nil { + return fmt.Errorf("delete tag %d: %w", id, err) + } + return tx.Commit(ctx) +} diff --git a/internal/db/tag_test.go b/internal/db/tag_test.go new file mode 100644 index 00000000..4c874342 --- /dev/null +++ b/internal/db/tag_test.go @@ -0,0 +1,56 @@ +package db + +import ( + "context" + "testing" +) + +func TestTagCloudSQLMatchesSchema(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + limit := 2 + + all, err := d.TagCloud(ctx, seedSiteID(t, d), nil) + if err != nil { + t.Fatalf("TagCloud(nil) err = %v, want nil", err) + } + limited, err := d.TagCloud(ctx, seedSiteID(t, d), &limit) + if err != nil { + t.Fatalf("TagCloud(2) err = %v, want nil", err) + } + if len(limited) != limit { + t.Errorf("len(TagCloud(2)) = %d, want %d", len(limited), limit) + } + if len(all) <= len(limited) { + t.Errorf("len(TagCloud(nil)) = %d, want more than %d", len(all), len(limited)) + } +} + +func TestTagCloudSkipsUnderscoreNames(t *testing.T) { + d := newTestDB(t) + + tags, err := d.TagCloud(context.Background(), seedSiteID(t, d), nil) + if err != nil { + t.Fatalf("TagCloud(nil) err = %v, want nil", err) + } + for _, tag := range tags { + if len(tag.Name) > 0 && tag.Name[0] == '_' { + t.Errorf("TagCloud() has %q, want no name starting with _", tag.Name) + } + } +} + +func TestTagCloudOrdersByArticleCount(t *testing.T) { + d := newTestDB(t) + + tags, err := d.TagCloud(context.Background(), seedSiteID(t, d), nil) + if err != nil { + t.Fatalf("TagCloud(nil) err = %v, want nil", err) + } + for i := 1; i < len(tags); i++ { + if tags[i-1].Articles < tags[i].Articles { + t.Errorf("TagCloud()[%d].Articles = %d, want at least %d", + i-1, tags[i-1].Articles, tags[i].Articles) + } + } +} diff --git a/internal/db/tag_write.go b/internal/db/tag_write.go new file mode 100644 index 00000000..563bc6a7 --- /dev/null +++ b/internal/db/tag_write.go @@ -0,0 +1,225 @@ +package db + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +const LogTags = "tags" + +const defaultTagCategory = "_default" + +type taggedName struct { + ID int64 `json:"id"` + Name string `json:"name"` +} + +var ( + qFindTagCategory = register("FindTagCategory", ` +SELECT id FROM web_tagscategory WHERE slug = $1 AND site_id = $2`) + + qInsertTagCategory = register("InsertTagCategory", ` +INSERT INTO web_tagscategory (name, description, slug, site_id) +VALUES ($1, '', $1, $2) +RETURNING id`) + + qFindTag = register("FindTag", ` +SELECT id FROM web_tag WHERE category_id = $1 AND name = $2`) + + qInsertTag = register("InsertTag", ` +INSERT INTO web_tag (category_id, name, site_id) VALUES ($1, $2, $3) RETURNING id`) + + qReadArticleTags = register("ReadArticleTags", ` +SELECT t.id, c.slug, t.name +FROM web_article_tags at +JOIN web_tag t ON t.id = at.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE at.article_id = $1 +ORDER BY t.id`) + + qDropArticleTags = register("DropArticleTags", ` +DELETE FROM web_article_tags WHERE article_id = $1 AND NOT (tag_id = ANY($2))`) + + qInsertArticleTag = register("InsertArticleTag", ` +INSERT INTO web_article_tags (article_id, tag_id) VALUES ($1, $2)`) + + qDropOrphanTags = register("DropOrphanTags", ` +DELETE FROM web_tag +WHERE site_id = $1 + AND NOT EXISTS (SELECT 1 FROM web_article_tags at WHERE at.tag_id = web_tag.id)`) + + // Only a category whose name was never set apart from its slug is swept up, + // which is how one somebody typed out survives losing its last tag. + qDropOrphanTagCategories = register("DropOrphanTagCategories", ` +DELETE FROM web_tagscategory +WHERE site_id = $1 AND slug = name + AND NOT EXISTS (SELECT 1 FROM web_tag t WHERE t.category_id = web_tagscategory.id)`) +) + +// A name with a space in it is dropped rather than refused, so one bad entry +// does not cost the page the rest of its tags. +func (d *DB) SetArticleTags(ctx context.Context, siteID, articleID int64, tags []string, + allowCreate bool, userID *int64, at time.Time) (Revision, bool, error) { + + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, false, fmt.Errorf("begin tags of %d: %w", articleID, err) + } + defer tx.Rollback(ctx) + + wanted, err := resolveTags(ctx, tx, siteID, tags, allowCreate) + if err != nil { + return Revision{}, false, fmt.Errorf("resolve tags of %d: %w", articleID, err) + } + held, err := readArticleTags(ctx, tx, articleID) + if err != nil { + return Revision{}, false, fmt.Errorf("read tags of %d: %w", articleID, err) + } + + added := tagsMissingFrom(wanted, held) + removed := tagsMissingFrom(held, wanted) + if len(added) == 0 && len(removed) == 0 { + return Revision{}, false, nil + } + + keep := make([]int64, 0, len(wanted)) + for _, tag := range wanted { + keep = append(keep, tag.ID) + } + if _, err := tx.Exec(ctx, qDropArticleTags, articleID, keep); err != nil { + return Revision{}, false, fmt.Errorf("drop tags of %d: %w", articleID, err) + } + for _, tag := range added { + if _, err := tx.Exec(ctx, qInsertArticleTag, articleID, tag.ID); err != nil { + return Revision{}, false, fmt.Errorf("tag %d: %w", articleID, err) + } + } + if allowCreate { + if _, err := tx.Exec(ctx, qDropOrphanTags, siteID); err != nil { + return Revision{}, false, fmt.Errorf("sweep tags: %w", err) + } + if _, err := tx.Exec(ctx, qDropOrphanTagCategories, siteID); err != nil { + return Revision{}, false, fmt.Errorf("sweep tag categories: %w", err) + } + } + + meta, err := json.Marshal(map[string]any{"added_tags": added, "removed_tags": removed}) + if err != nil { + return Revision{}, false, fmt.Errorf("encode tag meta of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qLockArticleLog, articleID); err != nil { + return Revision{}, false, fmt.Errorf("lock article log %d: %w", articleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, articleID, userID, LogTags, string(meta), "", at).Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, false, fmt.Errorf("write revision of %d: %w", articleID, err) + } + if _, err := tx.Exec(ctx, qTouchArticle, articleID, at); err != nil { + return Revision{}, false, fmt.Errorf("touch article %d: %w", articleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, false, fmt.Errorf("commit tags of %d: %w", articleID, err) + } + return rev, true, nil +} + +func resolveTags(ctx context.Context, tx pgx.Tx, siteID int64, tags []string, allowCreate bool) ([]taggedName, error) { + var out []taggedName + for _, raw := range tags { + if strings.Contains(raw, " ") { + continue + } + category, name := splitTagName(strings.ToLower(raw)) + id, err := tagID(ctx, tx, siteID, category, name, allowCreate) + if err != nil { + return nil, err + } + if id == 0 { + continue + } + tag := taggedName{ID: id, Name: tagFullName(category, name)} + if !slices.ContainsFunc(out, func(other taggedName) bool { return other.ID == tag.ID }) { + out = append(out, tag) + } + } + return out, nil +} + +func tagID(ctx context.Context, tx pgx.Tx, siteID int64, category, name string, allowCreate bool) (int64, error) { + var categoryID int64 + err := tx.QueryRow(ctx, qFindTagCategory, category, siteID).Scan(&categoryID) + if errors.Is(err, pgx.ErrNoRows) { + if !allowCreate { + return 0, nil + } + if err := tx.QueryRow(ctx, qInsertTagCategory, category, siteID).Scan(&categoryID); err != nil { + return 0, err + } + } else if err != nil { + return 0, err + } + + var id int64 + err = tx.QueryRow(ctx, qFindTag, categoryID, name).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + if !allowCreate { + return 0, nil + } + if err := tx.QueryRow(ctx, qInsertTag, categoryID, name, siteID).Scan(&id); err != nil { + return 0, err + } + } else if err != nil { + return 0, err + } + return id, nil +} + +func readArticleTags(ctx context.Context, tx pgx.Tx, articleID int64) ([]taggedName, error) { + rows, err := tx.Query(ctx, qReadArticleTags, articleID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []taggedName + for rows.Next() { + var id int64 + var category, name string + if err := rows.Scan(&id, &category, &name); err != nil { + return nil, err + } + out = append(out, taggedName{ID: id, Name: tagFullName(category, name)}) + } + return out, rows.Err() +} + +func splitTagName(full string) (category, name string) { + if c, n, ok := strings.Cut(full, ":"); ok { + return c, n + } + return defaultTagCategory, full +} + +func tagFullName(category, name string) string { + if category == defaultTagCategory { + return name + } + return category + ":" + name +} + +func tagsMissingFrom(want, have []taggedName) []taggedName { + out := []taggedName{} + for _, tag := range want { + if !slices.ContainsFunc(have, func(other taggedName) bool { return other.ID == tag.ID }) { + out = append(out, tag) + } + } + return out +} diff --git a/internal/db/tag_write_test.go b/internal/db/tag_write_test.go new file mode 100644 index 00000000..e2598ae5 --- /dev/null +++ b/internal/db/tag_write_test.go @@ -0,0 +1,191 @@ +package db + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +func tagNamesOf(t *testing.T, d *DB, articleID int64) []string { + t.Helper() + rows, err := d.pool.Query(context.Background(), ` +SELECT c.slug, t.name +FROM web_article_tags at +JOIN web_tag t ON t.id = at.tag_id +JOIN web_tagscategory c ON c.id = t.category_id +WHERE at.article_id = $1 +ORDER BY c.slug, t.name`, articleID) + if err != nil { + t.Fatalf("read tags err = %v, want nil", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var category, name string + if err := rows.Scan(&category, &name); err != nil { + t.Fatalf("scan tag err = %v, want nil", err) + } + out = append(out, tagFullName(category, name)) + } + return out +} + +func scratchTagged(t *testing.T, d *DB) int64 { + t.Helper() + id, err := d.CreateArticle(context.Background(), seedSiteID(t, d), "_default", + "probe-tags-"+time.Now().Format("20060102150405.000000"), "Probe", nil, time.Now().UTC()) + if err != nil { + t.Fatalf("CreateArticle() err = %v, want nil", err) + } + dropArticle(t, d, id) + t.Cleanup(func() { + clean := context.Background() + if _, err := d.pool.Exec(clean, `DELETE FROM web_article_tags WHERE article_id = $1`, id); err != nil { + t.Errorf("clean up tags err = %v, want nil", err) + } + if _, err := d.pool.Exec(clean, qDropOrphanTags, seedSiteID(t, d)); err != nil { + t.Errorf("sweep tags err = %v, want nil", err) + } + if _, err := d.pool.Exec(clean, qDropOrphanTagCategories, seedSiteID(t, d)); err != nil { + t.Errorf("sweep tag categories err = %v, want nil", err) + } + }) + return id +} + +func TestSetArticleTagsCreatesWhatItIsAllowedTo(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchTagged(t, d) + stamp := time.Now().Format("150405.000000") + + _, wrote, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, + []string{"probe" + stamp, "probecat" + stamp + ":one"}, true, nil, time.Now().UTC()) + if err != nil { + t.Fatalf("SetArticleTags() err = %v, want nil", err) + } + if !wrote { + t.Fatal("SetArticleTags() wrote no revision, want one") + } + + got := tagNamesOf(t, d, id) + want := []string{"probe" + stamp, "probecat" + stamp + ":one"} + if len(got) != len(want) { + t.Fatalf("tags = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("tags[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestSetArticleTagsSkipsWhatItMayNotCreate(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchTagged(t, d) + + _, wrote, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, + []string{"probe-never-seen-" + time.Now().Format("150405.000000")}, false, nil, time.Now().UTC()) + if err != nil { + t.Fatalf("SetArticleTags() err = %v, want nil", err) + } + if wrote { + t.Error("SetArticleTags() wrote a revision, want none") + } + if got := tagNamesOf(t, d, id); len(got) != 0 { + t.Errorf("tags = %v, want none", got) + } +} + +func TestSetArticleTagsDropsANameWithASpace(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchTagged(t, d) + stamp := time.Now().Format("150405.000000") + + if _, _, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, + []string{"two words", "probe" + stamp}, true, nil, time.Now().UTC()); err != nil { + t.Fatalf("SetArticleTags() err = %v, want nil", err) + } + got := tagNamesOf(t, d, id) + if len(got) != 1 || got[0] != "probe"+stamp { + t.Errorf("tags = %v, want [%q]", got, "probe"+stamp) + } +} + +func TestSetArticleTagsRecordsWhatMoved(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchTagged(t, d) + stamp := time.Now().Format("150405.000000") + at := time.Now().UTC() + + if _, _, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, []string{"probeold" + stamp}, true, nil, at); err != nil { + t.Fatalf("SetArticleTags(first) err = %v, want nil", err) + } + if _, _, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, []string{"probenew" + stamp}, true, nil, at.Add(time.Second)); err != nil { + t.Fatalf("SetArticleTags(second) err = %v, want nil", err) + } + + var raw []byte + if err := d.pool.QueryRow(ctx, + `SELECT meta FROM web_articlelogentry WHERE article_id = $1 AND rev_number = 1`, id).Scan(&raw); err != nil { + t.Fatalf("read revision err = %v, want nil", err) + } + var meta map[string][]taggedName + if err := json.Unmarshal(raw, &meta); err != nil { + t.Fatalf("decode meta err = %v, want nil", err) + } + if len(meta["added_tags"]) != 1 || meta["added_tags"][0].Name != "probenew"+stamp { + t.Errorf("meta added_tags = %v, want one named %q", meta["added_tags"], "probenew"+stamp) + } + if len(meta["removed_tags"]) != 1 || meta["removed_tags"][0].Name != "probeold"+stamp { + t.Errorf("meta removed_tags = %v, want one named %q", meta["removed_tags"], "probeold"+stamp) + } +} + +func TestSetArticleTagsKeepsQuietWhenNothingMoves(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchTagged(t, d) + stamp := time.Now().Format("150405.000000") + at := time.Now().UTC() + + if _, _, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, []string{"probe" + stamp}, true, nil, at); err != nil { + t.Fatalf("SetArticleTags(first) err = %v, want nil", err) + } + _, wrote, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, []string{"probe" + stamp}, true, nil, at.Add(time.Second)) + if err != nil { + t.Fatalf("SetArticleTags(second) err = %v, want nil", err) + } + if wrote { + t.Error("SetArticleTags() wrote a revision for an unchanged set, want none") + } +} + +func TestSetArticleTagsSweepsATagNothingCarries(t *testing.T) { + d := writeTestDB(t) + ctx := context.Background() + id := scratchTagged(t, d) + stamp := time.Now().Format("150405.000000") + at := time.Now().UTC() + + if _, _, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, []string{"probegone" + stamp}, true, nil, at); err != nil { + t.Fatalf("SetArticleTags(first) err = %v, want nil", err) + } + if _, _, err := d.SetArticleTags(ctx, seedSiteID(t, d), id, []string{"probekept" + stamp}, true, nil, at.Add(time.Second)); err != nil { + t.Fatalf("SetArticleTags(second) err = %v, want nil", err) + } + + var left int + if err := d.pool.QueryRow(ctx, + `SELECT count(*) FROM web_tag WHERE name = $1`, "probegone"+stamp).Scan(&left); err != nil { + t.Fatalf("count tags err = %v, want nil", err) + } + if left != 0 { + t.Errorf("count(tag nothing carries) = %d, want 0", left) + } +} diff --git a/internal/db/theme_write.go b/internal/db/theme_write.go new file mode 100644 index 00000000..5eda78f5 --- /dev/null +++ b/internal/db/theme_write.go @@ -0,0 +1,105 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type ThemeRow struct { + ID int64 + Name string + Slug string + Mode string + CSS string + ExternalURL string + UpdatedAt time.Time +} + +var qThemes = register("Themes", ` +SELECT id, name, slug, mode, css, external_url, updated_at +FROM web_theme WHERE site_id = $1 ORDER BY name, id`) + +func (d *DB) Themes(ctx context.Context, siteID int64) ([]ThemeRow, error) { + rows, err := d.pool.Query(ctx, qThemes, siteID) + if err != nil { + return nil, fmt.Errorf("list themes: %w", err) + } + defer rows.Close() + + var out []ThemeRow + for rows.Next() { + var t ThemeRow + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Mode, &t.CSS, &t.ExternalURL, &t.UpdatedAt); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +var qThemeRow = register("ThemeRow", ` +SELECT id, name, slug, mode, css, external_url, updated_at +FROM web_theme WHERE id = $1 AND site_id = $2`) + +func (d *DB) Theme(ctx context.Context, siteID, id int64) (ThemeRow, error) { + var t ThemeRow + err := d.pool.QueryRow(ctx, qThemeRow, id, siteID). + Scan(&t.ID, &t.Name, &t.Slug, &t.Mode, &t.CSS, &t.ExternalURL, &t.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return ThemeRow{}, ErrNotFound + } + if err != nil { + return ThemeRow{}, fmt.Errorf("read theme %d: %w", id, err) + } + return t, nil +} + +var qInsertTheme = register("InsertTheme", ` +INSERT INTO web_theme (name, slug, mode, css, external_url, updated_at, site_id) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`) + +var qUpdateTheme = register("UpdateTheme", ` +UPDATE web_theme SET name = $2, slug = $3, mode = $4, css = $5, external_url = $6, updated_at = $7 +WHERE id = $1 AND site_id = $8`) + +func (d *DB) SaveTheme(ctx context.Context, siteID int64, t ThemeRow) (int64, error) { + at := time.Now() + if t.ID == 0 { + var id int64 + err := d.pool.QueryRow(ctx, qInsertTheme, t.Name, t.Slug, t.Mode, t.CSS, t.ExternalURL, at, siteID).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create theme %q: %w", t.Slug, err) + } + return id, nil + } + if _, err := d.pool.Exec(ctx, qUpdateTheme, t.ID, t.Name, t.Slug, t.Mode, t.CSS, t.ExternalURL, at, siteID); err != nil { + return 0, fmt.Errorf("update theme %d: %w", t.ID, err) + } + return t.ID, nil +} + +var qDeleteTheme = register("DeleteTheme", `DELETE FROM web_theme WHERE id = $1 AND site_id = $2`) + +var qDetachTheme = register("DetachTheme", `UPDATE web_site SET + active_theme_id = CASE WHEN active_theme_id = $1 THEN NULL ELSE active_theme_id END, + system_theme_id = CASE WHEN system_theme_id = $1 THEN NULL ELSE system_theme_id END`) + +func (d *DB) DeleteTheme(ctx context.Context, siteID, id int64) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin deleting theme %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if _, err := tx.Exec(ctx, qDetachTheme, id); err != nil { + return fmt.Errorf("detach theme %d: %w", id, err) + } + if _, err := tx.Exec(ctx, qDeleteTheme, id, siteID); err != nil { + return fmt.Errorf("delete theme %d: %w", id, err) + } + return tx.Commit(ctx) +} diff --git a/internal/db/ticket_admin.go b/internal/db/ticket_admin.go new file mode 100644 index 00000000..308613ba --- /dev/null +++ b/internal/db/ticket_admin.go @@ -0,0 +1,187 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + TicketApproved = "approved" + TicketRejected = "rejected" + TicketClosed = "closed" +) + +type TicketRow struct { + ID int64 + Kind string + Author string + Subject string + Body string + SourcePage string + Status string + AdminNotes string + CreatedAt time.Time + ReviewedAt *time.Time + ReviewedBy string + GrantedID *int64 +} + +const ticketColumns = `t.id, t.kind, coalesce(a.username, ''), t.subject, t.body, t.source_page, + t.status, t.admin_notes, t.created_at, t.reviewed_at, coalesce(rev.username, ''), t.granted_role_id` + +const ticketJoins = ` +FROM web_userticket t +LEFT JOIN web_user a ON a.id = t.author_id +LEFT JOIN web_user rev ON rev.id = t.reviewed_by_id` + +var qAdminTickets = register("AdminTickets", ` +SELECT `+ticketColumns+ticketJoins+` +WHERE t.kind = $1 AND ($2 = '' OR t.status = $2) AND t.site_id = $5 +ORDER BY t.created_at DESC, t.id DESC +LIMIT $3 OFFSET $4`) + +var qAdminTicketCount = register("AdminTicketCount", ` +SELECT count(*) FROM web_userticket t WHERE t.kind = $1 AND ($2 = '' OR t.status = $2) AND t.site_id = $3`) + +func scanTicket(row pgx.Row, t *TicketRow) error { + return row.Scan(&t.ID, &t.Kind, &t.Author, &t.Subject, &t.Body, &t.SourcePage, + &t.Status, &t.AdminNotes, &t.CreatedAt, &t.ReviewedAt, &t.ReviewedBy, &t.GrantedID) +} + +func (d *DB) AdminTickets(ctx context.Context, siteID int64, kind, status string, limit, offset int) ([]TicketRow, int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAdminTicketCount, kind, status, siteID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count tickets: %w", err) + } + rows, err := d.pool.Query(ctx, qAdminTickets, kind, status, limit, offset, siteID) + if err != nil { + return nil, 0, fmt.Errorf("list tickets: %w", err) + } + defer rows.Close() + + var out []TicketRow + for rows.Next() { + var t TicketRow + if err := scanTicket(rows, &t); err != nil { + return nil, 0, err + } + out = append(out, t) + } + return out, total, rows.Err() +} + +var qAdminTicket = register("AdminTicket", `SELECT `+ticketColumns+ticketJoins+` WHERE t.id = $1 AND t.site_id = $2`) + +func (d *DB) AdminTicket(ctx context.Context, siteID, id int64) (TicketRow, error) { + var t TicketRow + err := scanTicket(d.pool.QueryRow(ctx, qAdminTicket, id, siteID), &t) + if errors.Is(err, pgx.ErrNoRows) { + return TicketRow{}, ErrNotFound + } + if err != nil { + return TicketRow{}, fmt.Errorf("read ticket %d: %w", id, err) + } + return t, nil +} + +var qReviewTicket = register("ReviewTicket", ` +UPDATE web_userticket SET status=$2, admin_notes=$3, reviewed_at=$4, reviewed_by_id=$5, granted_role_id=$6 +WHERE id=$1 AND site_id=$7`) + +var qGrantTicketRole = register("GrantTicketRole", ` +INSERT INTO web_user_roles (user_id, role_id) +SELECT t.author_id, $2 FROM web_userticket t +WHERE t.id = $1 AND t.author_id IS NOT NULL + AND EXISTS (SELECT 1 FROM web_role WHERE id = $2 AND site_id = $3) +ON CONFLICT DO NOTHING`) + +func (d *DB) ReviewTicket(ctx context.Context, siteID, id int64, status, notes string, by int64, role *int64, at time.Time) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin reviewing ticket %d: %w", id, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + var reviewedAt *time.Time + var reviewer *int64 + if status != TicketPending { + reviewedAt, reviewer = &at, &by + } + if _, err := tx.Exec(ctx, qReviewTicket, id, status, notes, reviewedAt, reviewer, role, siteID); err != nil { + return fmt.Errorf("review ticket %d: %w", id, err) + } + if status == TicketApproved && role != nil { + if _, err := tx.Exec(ctx, qGrantTicketRole, id, *role, siteID); err != nil { + return fmt.Errorf("grant the role of ticket %d: %w", id, err) + } + } + return tx.Commit(ctx) +} + +type InviteRow struct { + ID int64 + Kind string + Delivery string + Email string + WikidotUsername string + Token string + UIDB64 string + CreatedAt time.Time + ActivatedAt *time.Time + ActivatedUsername string +} + +var qAdminInvites = register("AdminInvites", ` +SELECT id, kind, delivery, email, wikidot_username, token, uidb64, + created_at, activated_at, activated_username +FROM web_invitelink WHERE site_id = $3 ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2`) + +var qAdminInviteCount = register("AdminInviteCount", `SELECT count(*) FROM web_invitelink WHERE site_id = $1`) + +var qOpenInviteCount = register("OpenInviteCount", ` +SELECT count(*) FROM web_invitelink WHERE activated_at IS NULL AND site_id = $1`) + +func (d *DB) OpenInviteCount(ctx context.Context, siteID int64) (int, error) { + var total int + if err := d.pool.QueryRow(ctx, qOpenInviteCount, siteID).Scan(&total); err != nil { + return 0, fmt.Errorf("count open invite links: %w", err) + } + return total, nil +} + +func (d *DB) AdminInvites(ctx context.Context, siteID int64, limit, offset int) ([]InviteRow, int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAdminInviteCount, siteID).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count invite links: %w", err) + } + rows, err := d.pool.Query(ctx, qAdminInvites, limit, offset, siteID) + if err != nil { + return nil, 0, fmt.Errorf("list invite links: %w", err) + } + defer rows.Close() + + var out []InviteRow + for rows.Next() { + var i InviteRow + err := rows.Scan(&i.ID, &i.Kind, &i.Delivery, &i.Email, &i.WikidotUsername, + &i.Token, &i.UIDB64, &i.CreatedAt, &i.ActivatedAt, &i.ActivatedUsername) + if err != nil { + return nil, 0, err + } + out = append(out, i) + } + return out, total, rows.Err() +} + +var qDeleteInvite = register("DeleteInvite", `DELETE FROM web_invitelink WHERE id = $1 AND activated_at IS NULL AND site_id = $2`) + +func (d *DB) DeleteInvite(ctx context.Context, siteID, id int64) error { + if _, err := d.pool.Exec(ctx, qDeleteInvite, id, siteID); err != nil { + return fmt.Errorf("delete invite link %d: %w", id, err) + } + return nil +} diff --git a/internal/db/update.go b/internal/db/update.go new file mode 100644 index 00000000..cbe9cead --- /dev/null +++ b/internal/db/update.go @@ -0,0 +1,124 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type UpdateState struct { + CheckedAt *time.Time + CheckError string + NextCheckAt *time.Time + LatestVersion string + LatestPublishedAt *time.Time + LatestPostgres string + LatestNotes string + ScheduledVersion string + ScheduledAt *time.Time + PostponedUntil *time.Time + SkippedVersion string + FailedVersions []string + PinnedVersion string + LastFrom string + LastTo string + LastOutcome string + LastError string + LastAt *time.Time + RollbackVersion string + RollbackKind string + RollbackExpiresAt *time.Time +} + +const updateColumns = `checked_at, check_error, next_check_at, latest_version, latest_published_at, +latest_postgres, latest_notes, scheduled_version, scheduled_at, postponed_until, skipped_version, +failed_versions, pinned_version, last_from, last_to, last_outcome, last_error, last_at, +rollback_version, rollback_kind, rollback_expires_at` + +var ( + qUpdateState = register("UpdateState", `SELECT `+updateColumns+` FROM pwikit_update WHERE id = 1`) + + qSaveUpdateState = register("SaveUpdateState", ` +INSERT INTO pwikit_update (id, `+updateColumns+`) +VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) +ON CONFLICT (id) DO UPDATE SET + checked_at = EXCLUDED.checked_at, check_error = EXCLUDED.check_error, + next_check_at = EXCLUDED.next_check_at, latest_version = EXCLUDED.latest_version, + latest_published_at = EXCLUDED.latest_published_at, latest_postgres = EXCLUDED.latest_postgres, + latest_notes = EXCLUDED.latest_notes, scheduled_version = EXCLUDED.scheduled_version, + scheduled_at = EXCLUDED.scheduled_at, postponed_until = EXCLUDED.postponed_until, + skipped_version = EXCLUDED.skipped_version, failed_versions = EXCLUDED.failed_versions, + pinned_version = EXCLUDED.pinned_version, last_from = EXCLUDED.last_from, + last_to = EXCLUDED.last_to, last_outcome = EXCLUDED.last_outcome, + last_error = EXCLUDED.last_error, last_at = EXCLUDED.last_at, + rollback_version = EXCLUDED.rollback_version, rollback_kind = EXCLUDED.rollback_kind, + rollback_expires_at = EXCLUDED.rollback_expires_at`) + + qSiteDomains = register("SiteDomains", `SELECT domain FROM web_site ORDER BY id`) + + qSuperuserEmails = register("SuperuserEmails", ` +SELECT email FROM web_user +WHERE is_superuser AND is_active AND email <> '' +ORDER BY id`) +) + +func (d *DB) UpdateState(ctx context.Context) (UpdateState, error) { + var s UpdateState + err := d.pool.QueryRow(ctx, qUpdateState).Scan( + &s.CheckedAt, &s.CheckError, &s.NextCheckAt, &s.LatestVersion, &s.LatestPublishedAt, + &s.LatestPostgres, &s.LatestNotes, &s.ScheduledVersion, &s.ScheduledAt, &s.PostponedUntil, + &s.SkippedVersion, &s.FailedVersions, &s.PinnedVersion, &s.LastFrom, &s.LastTo, + &s.LastOutcome, &s.LastError, &s.LastAt, &s.RollbackVersion, &s.RollbackKind, &s.RollbackExpiresAt) + var pgErr *pgconn.PgError + if errors.Is(err, pgx.ErrNoRows) || (errors.As(err, &pgErr) && pgErr.Code == "42P01") { + return UpdateState{}, nil + } + if err != nil { + return UpdateState{}, fmt.Errorf("read the update state: %w", err) + } + return s, nil +} + +func (d *DB) SaveUpdateState(ctx context.Context, s UpdateState) error { + if s.FailedVersions == nil { + s.FailedVersions = []string{} + } + _, err := d.pool.Exec(ctx, qSaveUpdateState, + s.CheckedAt, s.CheckError, s.NextCheckAt, s.LatestVersion, s.LatestPublishedAt, + s.LatestPostgres, s.LatestNotes, s.ScheduledVersion, s.ScheduledAt, s.PostponedUntil, + s.SkippedVersion, s.FailedVersions, s.PinnedVersion, s.LastFrom, s.LastTo, + s.LastOutcome, s.LastError, s.LastAt, s.RollbackVersion, s.RollbackKind, s.RollbackExpiresAt) + if err != nil { + return fmt.Errorf("write the update state: %w", err) + } + return nil +} + +func (d *DB) SiteDomains(ctx context.Context) ([]string, error) { + return d.strings(ctx, qSiteDomains, "list site domains") +} + +func (d *DB) SuperuserEmails(ctx context.Context) ([]string, error) { + return d.strings(ctx, qSuperuserEmails, "list superuser addresses") +} + +func (d *DB) strings(ctx context.Context, query, what string) ([]string, error) { + rows, err := d.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + defer rows.Close() + var out []string + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + return nil, err + } + out = append(out, value) + } + return out, rows.Err() +} diff --git a/internal/db/user.go b/internal/db/user.go new file mode 100644 index 00000000..725d2a44 --- /dev/null +++ b/internal/db/user.go @@ -0,0 +1,334 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +const ( + UserTypeNormal = "normal" + UserTypeWikidot = "wikidot" + UserTypeSystem = "system" + UserTypeBot = "bot" +) + +type User struct { + ID int64 + Type string + Username string + WikidotUsername string + DisplayName string + Avatar string + IsActive bool + InactiveUntil *time.Time + IsSuperuser bool + + IsForumActive bool + ForumInactiveUntil *time.Time + + CanSendDirectMessages bool + EmailVerifiedAt *time.Time + + Language string +} + +// A deadline in the future overrides the stored flag in both directions, so +// is_active is ignored whenever inactive_until is set. +func (u *User) ActiveAt(now time.Time) bool { + if u.InactiveUntil == nil { + return u.IsActive + } + return now.After(*u.InactiveUntil) +} + +func (u *User) ForumActiveAt(now time.Time) bool { + if u.ForumInactiveUntil == nil { + return u.IsForumActive + } + return now.After(*u.ForumInactiveUntil) +} + +func (u *User) DisplayLabel() string { + if u.Type == UserTypeWikidot { + return "wd:" + firstNonEmpty(u.DisplayName, u.WikidotUsername, u.Username) + } + return firstNonEmpty(u.DisplayName, u.Username) +} + +func (u *User) URLName() string { + if u.Type == UserTypeWikidot { + return firstNonEmpty(u.WikidotUsername, u.Username) + } + return u.Username +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +const userColumns = `id, type, username, wikidot_username, display_name, avatar, is_active, inactive_until, is_superuser, is_forum_active, forum_inactive_until, can_send_direct_messages, email_verified_at, language` + +var qUserByName = register("UserByName", ` +SELECT `+userColumns+` +FROM web_user +WHERE username = $1 OR wikidot_username = $1 +ORDER BY id +LIMIT 1`) + +var qUserByWikidotName = register("UserByWikidotName", ` +SELECT `+userColumns+` +FROM web_user +WHERE type = 'wikidot' AND wikidot_username = $1 +ORDER BY id +LIMIT 1`) + +// UserByName looks up a canonical username against both the local and the +// Wikidot name. Callers pass wikidot.CanonicalizeUsername output. +func (d *DB) UserByName(ctx context.Context, canonical string) (*User, error) { + return d.scanUser(ctx, qUserByName, canonical) +} + +var qUserByUsername = register("UserByUsername", ` +SELECT `+userColumns+` +FROM web_user +WHERE username = $1 +ORDER BY id +LIMIT 1`) + +// A page list that filters by author names the account as it is spelled here, +// never as it was on the site an imported account came from. +func (d *DB) UserByUsername(ctx context.Context, name string) (*User, error) { + return d.scanUser(ctx, qUserByUsername, name) +} + +var qUserByID = register("UserByID", ` +SELECT `+userColumns+` +FROM web_user +WHERE id = $1`) + +func (d *DB) UserByID(ctx context.Context, id int64) (*User, error) { + var u User + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, qUserByID, id).Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup user %d: %w", id, err) + } + finish() + return &u, nil +} + +var qUserByDisplayName = register("UserByDisplayName", ` +SELECT `+userColumns+` +FROM web_user +WHERE lower(display_name) = lower($1) +ORDER BY id +LIMIT 1`) + +// The oldest row wins so a later account cannot take over a name a page already +// points at. +func (d *DB) UserByDisplayName(ctx context.Context, name string) (*User, error) { + return d.scanUser(ctx, qUserByDisplayName, name) +} + +// The stored name is whatever the other site displayed, so canonicalizing +// first would compare a spaced name against a hyphenated one and never match. +func (d *DB) UserByWikidotName(ctx context.Context, name string) (*User, error) { + return d.scanUser(ctx, qUserByWikidotName, name) +} + +func (d *DB) scanUser(ctx context.Context, sql, canonical string) (*User, error) { + var u User + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, sql, canonical).Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("lookup user %q: %w", canonical, err) + } + finish() + return &u, nil +} + +// userDest lists the scan targets for userColumns, in order. Three of the +// columns are nullable text, so finish has to run before the user is read. +func userDest(u *User) (dest []any, finish func()) { + var wikidotUsername, displayName, avatar *string + dest = []any{ + &u.ID, &u.Type, &u.Username, &wikidotUsername, &displayName, &avatar, + &u.IsActive, &u.InactiveUntil, &u.IsSuperuser, + &u.IsForumActive, &u.ForumInactiveUntil, &u.CanSendDirectMessages, &u.EmailVerifiedAt, + &u.Language, + } + return dest, func() { + u.WikidotUsername = deref(wikidotUsername) + u.DisplayName = deref(displayName) + u.Avatar = deref(avatar) + } +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +var qUserForSession = register("UserForSession", ` +SELECT `+userColumns+`, password +FROM web_user +WHERE id = $1`) + +// UserForSession returns the password hash alongside the user because the +// session carries a hash of it; a session opened under an older password has to +// stop working. The hash is kept out of User so it cannot travel by accident. +func (d *DB) UserForSession(ctx context.Context, id int64) (*User, string, error) { + var ( + u User + password string + ) + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, qUserForSession, id).Scan(append(dest, &password)...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, "", ErrNotFound + } + if err != nil { + return nil, "", fmt.Errorf("lookup user %d: %w", id, err) + } + finish() + return &u, password, nil +} + +var qUsernamesLower = register("UsernamesLower", `SELECT lower(username) FROM web_user`) + +func (d *DB) UsernamesLower(ctx context.Context) (map[string]bool, error) { + rows, err := d.pool.Query(ctx, qUsernamesLower) + if err != nil { + return nil, fmt.Errorf("query usernames: %w", err) + } + defer rows.Close() + + out := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan username: %w", err) + } + out[name] = true + } + return out, rows.Err() +} + +var qAllUsers = register("AllUsers", ` +SELECT `+userColumns+` +FROM web_user +ORDER BY id`) + +func (d *DB) AllUsers(ctx context.Context) ([]User, error) { + rows, err := d.pool.Query(ctx, qAllUsers) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var out []User + for rows.Next() { + var u User + dest, finish := userDest(&u) + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + finish() + out = append(out, u) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + return out, nil +} + +var qUserByAnyName = register("UserByAnyName", ` +SELECT `+userColumns+` +FROM web_user +WHERE upper(username) = upper($1) + OR upper(wikidot_username) = upper($1) + OR upper(display_name) = upper($2) +ORDER BY id +LIMIT 1`) + +func (d *DB) UserByAnyName(ctx context.Context, canonical, raw string) (*User, error) { + var u User + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, qUserByAnyName, canonical, raw).Scan(dest...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("look up user %q: %w", raw, err) + } + finish() + return &u, nil +} + +var qUserForLogin = register("UserForLogin", ` +SELECT `+userColumns+`, password +FROM web_user +WHERE username = $1`) + +func (d *DB) UserForLogin(ctx context.Context, username string) (*User, string, error) { + var ( + u User + password string + ) + dest, finish := userDest(&u) + err := d.pool.QueryRow(ctx, qUserForLogin, username).Scan(append(dest, &password)...) + if errors.Is(err, pgx.ErrNoRows) { + return nil, "", ErrNotFound + } + if err != nil { + return nil, "", fmt.Errorf("look up user %q: %w", username, err) + } + finish() + return &u, password, nil +} + +var qSetPassword = register("SetPassword", ` +UPDATE web_user SET password = $2 WHERE id = $1`) + +func (d *DB) SetPassword(ctx context.Context, id int64, hash string) error { + if _, err := d.pool.Exec(ctx, qSetPassword, id, hash); err != nil { + return fmt.Errorf("store password of user %d: %w", id, err) + } + return nil +} + +var qSetLastLogin = register("SetLastLogin", ` +UPDATE web_user SET last_login = $2 WHERE id = $1`) + +func (d *DB) SetLastLogin(ctx context.Context, id int64, at time.Time) error { + if _, err := d.pool.Exec(ctx, qSetLastLogin, id, at); err != nil { + return fmt.Errorf("store last login of user %d: %w", id, err) + } + return nil +} + +var qBotByAPIKey = register("BotByAPIKey", ` +SELECT `+userColumns+` +FROM web_user +WHERE type = 'bot' AND api_key = $1`) + +func (d *DB) BotByAPIKey(ctx context.Context, key string) (*User, error) { + return d.scanUser(ctx, qBotByAPIKey, key) +} diff --git a/internal/db/user_admin.go b/internal/db/user_admin.go new file mode 100644 index 00000000..d20ef233 --- /dev/null +++ b/internal/db/user_admin.go @@ -0,0 +1,204 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type AdminUserRow struct { + ID int64 + Type string + Username string + WikidotUsername string + DisplayName string + Email string + Bio string + Avatar string + APIKey string + + IsActive bool + InactiveUntil *time.Time + IsForumActive bool + ForumInactiveUntil *time.Time + CanSendDM bool + IsSuperuser bool + + OperationIndex int + Roles []int64 +} + +const adminUserColumns = `id, type, username, coalesce(wikidot_username, ''), coalesce(display_name, ''), + coalesce(email, ''), coalesce(bio, ''), coalesce(avatar, ''), coalesce(api_key, ''), + is_active, inactive_until, is_forum_active, forum_inactive_until, + can_send_direct_messages, is_superuser` + +const adminUserWhere = ` +WHERE ($1 = '' OR username ILIKE '%' || $1 || '%' OR wikidot_username ILIKE '%' || $1 || '%' + OR display_name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%') + AND ($2 = '' OR type = $2)` + +var qAdminUsers = register("AdminUsers", ` +SELECT `+adminUserColumns+` +FROM web_user`+adminUserWhere+` +ORDER BY CASE WHEN type = 'wikidot' THEN wikidot_username ELSE username END, id +LIMIT $3 OFFSET $4`) + +var qAdminUserCount = register("AdminUserCount", ` +SELECT count(*) FROM web_user`+adminUserWhere) + +func scanAdminUser(row pgx.Row, u *AdminUserRow) error { + return row.Scan(&u.ID, &u.Type, &u.Username, &u.WikidotUsername, &u.DisplayName, + &u.Email, &u.Bio, &u.Avatar, &u.APIKey, &u.IsActive, &u.InactiveUntil, + &u.IsForumActive, &u.ForumInactiveUntil, &u.CanSendDM, &u.IsSuperuser) +} + +func (d *DB) AdminUsers(ctx context.Context, query, kind string, limit, offset int) ([]AdminUserRow, int, error) { + var total int + if err := d.pool.QueryRow(ctx, qAdminUserCount, query, kind).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + rows, err := d.pool.Query(ctx, qAdminUsers, query, kind, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + + var out []AdminUserRow + for rows.Next() { + var u AdminUserRow + if err := scanAdminUser(rows, &u); err != nil { + return nil, 0, err + } + out = append(out, u) + } + return out, total, rows.Err() +} + +var qAdminUser = register("AdminUser", `SELECT `+adminUserColumns+` FROM web_user WHERE id = $1`) + +var qAdminUserRoles = register("AdminUserRoles", ` +SELECT ur.role_id FROM web_user_roles ur +JOIN web_role r ON r.id = ur.role_id +WHERE ur.user_id = $1 AND r.site_id = $2`) + +func (d *DB) AdminUser(ctx context.Context, siteID, id int64) (AdminUserRow, error) { + var u AdminUserRow + err := scanAdminUser(d.pool.QueryRow(ctx, qAdminUser, id), &u) + if errors.Is(err, pgx.ErrNoRows) { + return AdminUserRow{}, ErrNotFound + } + if err != nil { + return AdminUserRow{}, fmt.Errorf("read user %d: %w", id, err) + } + + rows, err := d.pool.Query(ctx, qAdminUserRoles, id, siteID) + if err != nil { + return AdminUserRow{}, fmt.Errorf("read the roles of user %d: %w", id, err) + } + defer rows.Close() + for rows.Next() { + var roleID int64 + if err := rows.Scan(&roleID); err != nil { + return AdminUserRow{}, err + } + u.Roles = append(u.Roles, roleID) + } + if err := rows.Err(); err != nil { + return AdminUserRow{}, err + } + u.OperationIndex, err = d.OperationIndex(ctx, siteID, id) + return u, err +} + +var ( + qUpdateAdminUser = register("UpdateAdminUser", ` +UPDATE web_user SET username=$2, wikidot_username=$3, display_name=$4, email=$5, bio=$6, + is_active=$7, inactive_until=$8, is_forum_active=$9, forum_inactive_until=$10, + can_send_direct_messages=$11 +WHERE id=$1`) + + qSetSuperuser = register("SetSuperuser", `UPDATE web_user SET is_superuser = $2 WHERE id = $1`) + qClearUserRole = register("ClearUserRoles", ` +DELETE FROM web_user_roles ur USING web_role r +WHERE ur.role_id = r.id AND ur.user_id = $1 AND r.site_id = $2`) + qAddUserRole = register("AddUserRoles", ` +INSERT INTO web_user_roles (user_id, role_id) +SELECT $1, r.id FROM web_role r WHERE r.id = ANY($2) AND r.slug <> ALL($3) AND r.site_id = $4`) +) + +func (d *DB) SaveAdminUser(ctx context.Context, siteID int64, u AdminUserRow, builtin []string, withRoles, withSuperuser bool) error { + tx, err := d.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin saving user %d: %w", u.ID, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + _, err = tx.Exec(ctx, qUpdateAdminUser, u.ID, u.Username, nullable(u.WikidotUsername), + nullable(u.DisplayName), u.Email, u.Bio, u.IsActive, u.InactiveUntil, + u.IsForumActive, u.ForumInactiveUntil, u.CanSendDM) + if err != nil { + return fmt.Errorf("save user %d: %w", u.ID, err) + } + if withSuperuser { + if _, err := tx.Exec(ctx, qSetSuperuser, u.ID, u.IsSuperuser); err != nil { + return fmt.Errorf("set the superuser flag on %d: %w", u.ID, err) + } + } + if withRoles { + if _, err := tx.Exec(ctx, qClearUserRole, u.ID, siteID); err != nil { + return err + } + if len(u.Roles) > 0 { + if _, err := tx.Exec(ctx, qAddUserRole, u.ID, u.Roles, builtin, siteID); err != nil { + return fmt.Errorf("give roles to user %d: %w", u.ID, err) + } + } + } + return tx.Commit(ctx) +} + +var qResetUserVotes = register("ResetUserVotes", ` +DELETE FROM web_vote v +USING web_article a +WHERE a.id = v.article_id AND v.user_id = $1 AND a.site_id = $2`) + +func (d *DB) ResetUserVotes(ctx context.Context, siteID, userID int64) (int64, error) { + tag, err := d.pool.Exec(ctx, qResetUserVotes, userID, siteID) + if err != nil { + return 0, fmt.Errorf("reset votes of user %d: %w", userID, err) + } + return tag.RowsAffected(), nil +} + +var qUnclaimedWikidotUsers = register("UnclaimedWikidotUsers", ` +SELECT id, coalesce(wikidot_username, '') +FROM web_user +WHERE type = 'wikidot' AND NOT is_active +ORDER BY wikidot_username, id`) + +type UserChoice struct { + ID int64 + Name string +} + +func (d *DB) UnclaimedWikidotUsers(ctx context.Context) ([]UserChoice, error) { + rows, err := d.pool.Query(ctx, qUnclaimedWikidotUsers) + if err != nil { + return nil, fmt.Errorf("list unclaimed wikidot users: %w", err) + } + defer rows.Close() + + var out []UserChoice + for rows.Next() { + var one UserChoice + if err := rows.Scan(&one.ID, &one.Name); err != nil { + return nil, err + } + out = append(out, one) + } + return out, rows.Err() +} diff --git a/internal/db/user_test.go b/internal/db/user_test.go new file mode 100644 index 00000000..dc0afa8e --- /dev/null +++ b/internal/db/user_test.go @@ -0,0 +1,64 @@ +package db + +import ( + "context" + "errors" + "testing" +) + +func TestUserByName(t *testing.T) { + d := newTestDB(t) + + got, err := d.UserByName(context.Background(), "seeduser") + if err != nil { + t.Fatalf("UserByName(\"seeduser\") err = %v, want nil", err) + } + if got.Username != "seeduser" { + t.Errorf("UserByName().Username = %q, want %q", got.Username, "seeduser") + } + if got.Type != UserTypeNormal { + t.Errorf("UserByName().Type = %q, want %q", got.Type, UserTypeNormal) + } +} + +func TestUserByNameUnknown(t *testing.T) { + d := newTestDB(t) + + _, err := d.UserByName(context.Background(), "no-such-user") + if !errors.Is(err, ErrNotFound) { + t.Errorf("UserByName(\"no-such-user\") err = %v, want ErrNotFound", err) + } +} + +func TestUserByDisplayName(t *testing.T) { + d := newTestDB(t) + + got, err := d.UserByDisplayName(context.Background(), "Probe WD") + if err != nil { + t.Fatalf("UserByDisplayName() err = %v, want nil", err) + } + if got.WikidotUsername != "probe-wd-original" { + t.Errorf("UserByDisplayName(Probe WD).WikidotUsername = %q, want %q", got.WikidotUsername, "probe-wd-original") + } +} + +func TestUserByDisplayNameIgnoresCase(t *testing.T) { + d := newTestDB(t) + + got, err := d.UserByDisplayName(context.Background(), "pRoBe wD") + if err != nil { + t.Fatalf("UserByDisplayName() err = %v, want nil", err) + } + if got.DisplayName != "Probe WD" { + t.Errorf("UserByDisplayName(pRoBe wD).DisplayName = %q, want %q", got.DisplayName, "Probe WD") + } +} + +func TestUserByDisplayNameUnknown(t *testing.T) { + d := newTestDB(t) + + _, err := d.UserByDisplayName(context.Background(), "No Such Display Name") + if !errors.Is(err, ErrNotFound) { + t.Errorf("UserByDisplayName(unknown) err = %v, want ErrNotFound", err) + } +} diff --git a/internal/db/vote.go b/internal/db/vote.go new file mode 100644 index 00000000..808daa60 --- /dev/null +++ b/internal/db/vote.go @@ -0,0 +1,191 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type Vote struct { + ID int64 + Rate float64 + Date *time.Time + RoleID *int64 +} + +type ArticleVote struct { + User User + Rate float64 + Date *time.Time + RoleID *int64 + GroupTitle string + GroupIndex *int +} + +var qArticleVotes = register("ArticleVotes", ` +SELECT `+prefixed("u", userColumns)+`, v.rate, v.date, v.role_id, + COALESCE(NULLIF(r.votes_title, ''), NULLIF(r.name, ''), r.slug), r.index +FROM web_vote v +JOIN web_user u ON u.id = v.user_id +LEFT JOIN web_role r ON r.id = v.role_id +WHERE v.article_id = $1 +ORDER BY v.date DESC, u.username DESC`) + +func (d *DB) ArticleVotes(ctx context.Context, articleID int64) ([]ArticleVote, error) { + rows, err := d.pool.Query(ctx, qArticleVotes, articleID) + if err != nil { + return nil, fmt.Errorf("query votes of article %d: %w", articleID, err) + } + defer rows.Close() + + var out []ArticleVote + for rows.Next() { + var v ArticleVote + var title *string + dest, finish := userDest(&v.User) + dest = append(dest, &v.Rate, &v.Date, &v.RoleID, &title, &v.GroupIndex) + if err := rows.Scan(dest...); err != nil { + return nil, fmt.Errorf("scan vote: %w", err) + } + finish() + if title != nil { + v.GroupTitle = *title + } + out = append(out, v) + } + return out, rows.Err() +} + +var ( + qVoteOfUser = register("VoteOfUser", ` +SELECT id, rate, date, role_id +FROM web_vote +WHERE article_id = $1 AND user_id = $2`) + + qDeleteVotesOfUser = register("DeleteVotesOfUser", ` +DELETE FROM web_vote WHERE article_id = $1 AND user_id = $2`) + + qInsertVote = register("InsertVote", ` +INSERT INTO web_vote (article_id, user_id, rate, date, role_id) +VALUES ($1, $2, $3, $4, $5)`) +) + +// The vote that was there comes back, since the action log records what +// changed rather than what it changed to. +func (d *DB) ReplaceVote(ctx context.Context, articleID, userID int64, rate *float64, roleID *int64, at time.Time) (*Vote, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("begin vote: %w", err) + } + defer tx.Rollback(ctx) + + var old *Vote + var found Vote + err = tx.QueryRow(ctx, qVoteOfUser, articleID, userID).Scan(&found.ID, &found.Rate, &found.Date, &found.RoleID) + switch { + case err == nil: + old = &found + case !errors.Is(err, pgx.ErrNoRows): + return nil, fmt.Errorf("read vote: %w", err) + } + + if _, err := tx.Exec(ctx, qDeleteVotesOfUser, articleID, userID); err != nil { + return nil, fmt.Errorf("delete vote: %w", err) + } + if rate != nil { + if _, err := tx.Exec(ctx, qInsertVote, articleID, userID, *rate, at, roleID); err != nil { + return nil, fmt.Errorf("insert vote: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit vote: %w", err) + } + return old, nil +} + +var qDeleteArticleVotes = register("DeleteArticleVotes", ` +DELETE FROM web_vote WHERE article_id = $1`) + +func (d *DB) DeleteArticleVotes(ctx context.Context, articleID int64) error { + if _, err := d.pool.Exec(ctx, qDeleteArticleVotes, articleID); err != nil { + return fmt.Errorf("delete votes of article %d: %w", articleID, err) + } + return nil +} + +var qVoteGroupRole = register("VoteGroupRole", ` +SELECT r.id +FROM web_role r +LEFT JOIN web_user_roles link ON link.role_id = r.id AND link.user_id = $1 +WHERE r.site_id = $3 AND r.group_votes AND (r.slug = ANY($2) OR link.user_id IS NOT NULL) +ORDER BY CASE r.slug WHEN 'registered' THEN 0 WHEN 'everyone' THEN 1 ELSE 2 END, r.index +LIMIT 1`) + +// VoteGroupRole answers which role a vote is filed under. The two built-in +// roles outrank the user's own, and for an anonymous reader only everyone can. +func (d *DB) VoteGroupRole(ctx context.Context, siteID int64, userID *int64) (*int64, error) { + slugs := []string{"everyone"} + var of int64 + if userID != nil { + slugs = append(slugs, "registered") + of = *userID + } + + var id int64 + err := d.pool.QueryRow(ctx, qVoteGroupRole, of, slugs, siteID).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query vote role: %w", err) + } + return &id, nil +} + +const ( + LogVotesDeleted = "votes_deleted" +) + +var ( + qLockArticleLog = register("LockArticleLog", `SELECT pg_advisory_xact_lock($1)`) + + qInsertArticleLog = register("InsertArticleLog", ` +INSERT INTO web_articlelogentry (article_id, user_id, type, meta, comment, created_at, rev_number) +SELECT $1, $2, $3, $4, $5, $6, COALESCE(MAX(rev_number), -1) + 1 +FROM web_articlelogentry WHERE article_id = $1 +RETURNING id, rev_number`) + + qTouchArticle = register("TouchArticle", ` +UPDATE web_article SET updated_at = $2 WHERE id = $1`) +) + +// The revision is numbered under a lock on this article alone, so two writers +// cannot pick the same number and neither waits on a page it is not touching. +func (d *DB) AddArticleLogEntry(ctx context.Context, articleID int64, userID *int64, + kind, comment, meta string, at time.Time) (Revision, error) { + + tx, err := d.pool.Begin(ctx) + if err != nil { + return Revision{}, fmt.Errorf("begin log entry: %w", err) + } + defer tx.Rollback(ctx) + + if _, err := tx.Exec(ctx, qLockArticleLog, articleID); err != nil { + return Revision{}, fmt.Errorf("lock article log %d: %w", articleID, err) + } + var rev Revision + if err := tx.QueryRow(ctx, qInsertArticleLog, articleID, userID, kind, meta, comment, at). + Scan(&rev.EntryID, &rev.RevNumber); err != nil { + return Revision{}, fmt.Errorf("write log entry: %w", err) + } + if _, err := tx.Exec(ctx, qTouchArticle, articleID, at); err != nil { + return Revision{}, fmt.Errorf("touch article %d: %w", articleID, err) + } + if err := tx.Commit(ctx); err != nil { + return Revision{}, fmt.Errorf("commit log entry: %w", err) + } + return rev, nil +} diff --git a/internal/db/wantedpages.go b/internal/db/wantedpages.go new file mode 100644 index 00000000..f1b68768 --- /dev/null +++ b/internal/db/wantedpages.go @@ -0,0 +1,95 @@ +package db + +import ( + "context" + "fmt" + "strconv" + "strings" +) + +type WantedLink struct { + From string + To string + Title string +} + +type WantedFilter struct { + From []string + Categories []string + NotCategories []string +} + +// A link recorded without a category belongs to the default one, on both ends, +// and the stored text carries no hint of that. +const ( + completeFrom = `(CASE WHEN position(':' in l.link_from) > 0 THEN l.link_from::text ELSE '_default:' || l.link_from::text END)` + completeTo = `(CASE WHEN position(':' in l.link_to) > 0 THEN l.link_to::text ELSE '_default:' || l.link_to::text END)` + categoryOfTo = `(CASE WHEN position(':' in l.link_to) > 0 THEN substring(l.link_to::text from 1 for position(':' in l.link_to) - 1) ELSE '_default' END)` +) + +func (f WantedFilter) where(args *[]any) string { + *args = append(*args, lowerAll(f.From)) + var b strings.Builder + b.WriteString("WHERE l.link_type = 'link'\n AND lower(" + completeFrom + ") = ANY($1)" + + "\n AND NOT EXISTS (SELECT 1 FROM web_article a WHERE a.complete_full_name = " + completeTo + ")") + if len(f.Categories) > 0 { + *args = append(*args, lowerAll(f.Categories)) + b.WriteString("\n AND lower(" + categoryOfTo + ") = ANY($" + strconv.Itoa(len(*args)) + ")") + } + if len(f.NotCategories) > 0 { + *args = append(*args, lowerAll(f.NotCategories)) + b.WriteString("\n AND NOT (lower(" + categoryOfTo + ") = ANY($" + strconv.Itoa(len(*args)) + "))") + } + return b.String() +} + +// The link table has no order of its own, so the row id is what keeps a page of +// results from reshuffling between requests. +func (d *DB) WantedLinks(ctx context.Context, f WantedFilter, offset, limit int) ([]WantedLink, error) { + args := []any{} + where := f.where(&args) + args = append(args, limit, offset) + sql := `SELECT l.link_from, l.link_to, coalesce(src.title, '') +FROM web_externallink l +LEFT JOIN web_article src ON src.complete_full_name = ` + completeFrom + "\n" + where + + "\nORDER BY l.id" + + "\nLIMIT $" + strconv.Itoa(len(args)-1) + " OFFSET $" + strconv.Itoa(len(args)) + + rows, err := d.pool.Query(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("query wanted links: %w", err) + } + defer rows.Close() + + var out []WantedLink + for rows.Next() { + var link WantedLink + if err := rows.Scan(&link.From, &link.To, &link.Title); err != nil { + return nil, fmt.Errorf("scan wanted link: %w", err) + } + out = append(out, link) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("read wanted links: %w", err) + } + return out, nil +} + +func (d *DB) WantedLinkCount(ctx context.Context, f WantedFilter) (int, error) { + args := []any{} + sql := "SELECT count(*)\nFROM web_externallink l\n" + f.where(&args) + + var total int + if err := d.pool.QueryRow(ctx, sql, args...).Scan(&total); err != nil { + return 0, fmt.Errorf("count wanted links: %w", err) + } + return total, nil +} + +func lowerAll(values []string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = strings.ToLower(value) + } + return out +} diff --git a/internal/db/wantedpages_test.go b/internal/db/wantedpages_test.go new file mode 100644 index 00000000..51655ef0 --- /dev/null +++ b/internal/db/wantedpages_test.go @@ -0,0 +1,59 @@ +package db + +import ( + "context" + "testing" +) + +func TestWantedLinkSQLMatchesSchema(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + + for name, filter := range map[string]WantedFilter{ + "empty": {}, + "from": {From: []string{"probestars:unrated"}}, + "categories": {From: []string{"probestars:unrated"}, Categories: []string{"wanted"}}, + "not-categories": {From: []string{"probestars:unrated"}, NotCategories: []string{"wanted"}}, + "both": {From: []string{"probestars:unrated"}, Categories: []string{"wanted"}, NotCategories: []string{"probe"}}, + } { + if _, err := d.WantedLinks(ctx, filter, 0, 20); err != nil { + t.Errorf("WantedLinks(%s) err = %v, want nil", name, err) + } + if _, err := d.WantedLinkCount(ctx, filter); err != nil { + t.Errorf("WantedLinkCount(%s) err = %v, want nil", name, err) + } + } +} + +func TestWantedLinkCountAgreesWithTheRows(t *testing.T) { + d := newTestDB(t) + ctx := context.Background() + filter := WantedFilter{From: []string{"probestars:unrated", "probestars:quarter", "probeoff:unratable"}} + + rows, err := d.WantedLinks(ctx, filter, 0, 100) + if err != nil { + t.Fatalf("WantedLinks() err = %v, want nil", err) + } + total, err := d.WantedLinkCount(ctx, filter) + if err != nil { + t.Fatalf("WantedLinkCount() err = %v, want nil", err) + } + if total != len(rows) { + t.Errorf("WantedLinkCount() = %d, want %d", total, len(rows)) + } +} + +func TestWantedLinksSkipPagesThatExist(t *testing.T) { + d := newTestDB(t) + filter := WantedFilter{From: []string{"probeoff:unratable"}} + + rows, err := d.WantedLinks(context.Background(), filter, 0, 100) + if err != nil { + t.Fatalf("WantedLinks() err = %v, want nil", err) + } + for _, row := range rows { + if row.To == "probe:full" { + t.Errorf("WantedLinks() has %q, want only names with no page", row.To) + } + } +} diff --git a/internal/entry/entry.go b/internal/entry/entry.go new file mode 100644 index 00000000..45cfadb1 --- /dev/null +++ b/internal/entry/entry.go @@ -0,0 +1,323 @@ +// Package entry answers how pwikit gets its listening sockets and who +// terminates TLS. +package entry + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" + + "github.com/WikitTeam/ProjectWikit/internal/site" +) + +type Mode string + +const ( + Off Mode = "off" + File Mode = "file" + Auto Mode = "auto" +) + +var modes = []Mode{Off, File, Auto} + +const ( + socketPlain = "http" + socketSecure = "https" + + readHeaderTimeout = 20 * time.Second + shutdownGrace = 15 * time.Second +) + +func ParseMode(raw string) (Mode, error) { + m := Mode(strings.ToLower(strings.TrimSpace(raw))) + if slices.Contains(modes, m) { + return m, nil + } + return "", fmt.Errorf("unknown tls mode %q, want off, file or auto", raw) +} + +type Hosts func(ctx context.Context, host string) error + +type Config struct { + Mode Mode + Plain string + Secure string + CertFile string + KeyFile string + CacheDir string + Email string + Directory string + Hosts Hosts + Handler http.Handler + Logger *slog.Logger +} + +func (c Config) check() error { + if c.Handler == nil { + return errors.New("no handler") + } + if c.Plain == "" { + return errors.New("no plain listen address") + } + switch c.Mode { + case Off: + return nil + case File: + if c.CertFile == "" || c.KeyFile == "" { + return errors.New("tls mode file needs a certificate and a private key") + } + case Auto: + if c.CacheDir == "" { + return errors.New("tls mode auto needs a directory to keep certificates in") + } + if c.Hosts == nil { + return errors.New("tls mode auto needs to know which hosts belong to this server") + } + default: + return fmt.Errorf("unknown tls mode %q", c.Mode) + } + if c.Secure == "" { + return errors.New("no https listen address") + } + return nil +} + +func Serve(ctx context.Context, cfg Config) error { + if err := cfg.check(); err != nil { + return err + } + log := cfg.Logger + if log == nil { + log = slog.Default() + } + + plain := cfg.Handler + var secure *http.Server + + switch cfg.Mode { + case File: + keeper := &certKeeper{certFile: cfg.CertFile, keyFile: cfg.KeyFile, recheck: time.Minute, log: log} + if _, err := keeper.reload(); err != nil { + return err + } + plain = redirectSecure(cfg.Secure) + secure = server(cfg.Handler, &tls.Config{GetCertificate: keeper.get, MinVersion: tls.VersionTLS12}) + case Auto: + manager := &autocert.Manager{ + Cache: autocert.DirCache(cfg.CacheDir), + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostPolicy(cfg.Hosts), + Email: cfg.Email, + } + if cfg.Directory != "" { + manager.Client = &acme.Client{DirectoryURL: cfg.Directory} + } + plain = manager.HTTPHandler(nil) + secure = server(cfg.Handler, manager.TLSConfig()) + } + + in := inheritedSockets() + plainListener, err := listen(socketPlain, cfg.Plain, in) + if err != nil { + return err + } + running := []*http.Server{server(plain, nil)} + listeners := []net.Listener{plainListener} + if secure != nil { + secureListener, err := listen(socketSecure, cfg.Secure, in) + if err != nil { + plainListener.Close() + return err + } + running = append(running, secure) + listeners = append(listeners, tls.NewListener(secureListener, secure.TLSConfig)) + } + + log.Info("pwikit listening", "tls", string(cfg.Mode), "plain", plainListener.Addr().String(), + "secure", secureAddr(listeners), "inherited", len(in)) + + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + errs := make(chan error, len(running)) + for i, s := range running { + go func() { errs <- s.Serve(listeners[i]) }() + } + + var first error + select { + case first = <-errs: + case <-ctx.Done(): + } + stop() + + closing, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownGrace) + defer cancel() + var wg sync.WaitGroup + for _, s := range running { + wg.Add(1) + go func() { + defer wg.Done() + s.Shutdown(closing) + }() + } + wg.Wait() + + if first != nil && !errors.Is(first, http.ErrServerClosed) { + return first + } + return nil +} + +func server(h http.Handler, conf *tls.Config) *http.Server { + return &http.Server{Handler: h, TLSConfig: conf, ReadHeaderTimeout: readHeaderTimeout} +} + +func secureAddr(listeners []net.Listener) string { + if len(listeners) < 2 { + return "" + } + return listeners[1].Addr().String() +} + +func redirectSecure(secure string) http.Handler { + _, port, _ := net.SplitHostPort(secure) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + host := site.StripPort(r.Host) + if port != "" && port != "443" { + host = net.JoinHostPort(host, port) + } + http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusFound) + }) +} + +type certKeeper struct { + certFile string + keyFile string + recheck time.Duration + log *slog.Logger + + mu sync.Mutex + cert *tls.Certificate + stamp [2]time.Time + checked time.Time +} + +func (k *certKeeper) get(*tls.ClientHelloInfo) (*tls.Certificate, error) { + k.mu.Lock() + defer k.mu.Unlock() + if k.cert != nil && time.Since(k.checked) < k.recheck { + return k.cert, nil + } + return k.reload() +} + +func (k *certKeeper) reload() (*tls.Certificate, error) { + stamp, err := k.stamps() + k.checked = time.Now() + if err == nil && k.cert != nil && stamp == k.stamp { + return k.cert, nil + } + pair, err := tls.LoadX509KeyPair(k.certFile, k.keyFile) + if err != nil { + if k.cert == nil { + return nil, fmt.Errorf("load certificate %q with key %q: %w", k.certFile, k.keyFile, err) + } + k.log.Error("reload certificate", "cert", k.certFile, "key", k.keyFile, "err", err) + return k.cert, nil + } + k.cert, k.stamp = &pair, stamp + return k.cert, nil +} + +func (k *certKeeper) stamps() ([2]time.Time, error) { + var stamp [2]time.Time + for i, name := range []string{k.certFile, k.keyFile} { + info, err := os.Stat(name) + if err != nil { + return stamp, err + } + stamp[i] = info.ModTime() + } + return stamp, nil +} + +func listen(name, addr string, in map[string]*os.File) (net.Listener, error) { + if f := in[name]; f != nil { + l, err := net.FileListener(f) + if err != nil { + return nil, fmt.Errorf("adopt the %s socket handed over at startup: %w", name, err) + } + return l, nil + } + l, err := net.Listen("tcp", addr) + if err != nil { + if privileged(addr) && errors.Is(err, os.ErrPermission) { + return nil, fmt.Errorf("listen on %s for %s: %w. Grant the binary CAP_NET_BIND_SERVICE, hand the socket over with systemd socket activation, or listen above port 1024 behind a proxy", addr, name, err) + } + return nil, fmt.Errorf("listen on %s for %s: %w", addr, name, err) + } + return l, nil +} + +func privileged(addr string) bool { + _, port, err := net.SplitHostPort(addr) + if err != nil { + return false + } + n, err := strconv.Atoi(port) + return err == nil && n > 0 && n < 1024 +} + +func inheritedSockets() map[string]*os.File { + if strconv.Itoa(os.Getpid()) != os.Getenv("LISTEN_PID") { + return nil + } + count, err := strconv.Atoi(os.Getenv("LISTEN_FDS")) + if err != nil || count <= 0 { + return nil + } + out := make(map[string]*os.File, count) + for i, name := range socketNames(count, os.Getenv("LISTEN_FDNAMES")) { + if name == "" { + continue + } + out[name] = os.NewFile(uintptr(3+i), name) + } + return out +} + +func socketNames(count int, raw string) []string { + given := strings.Split(raw, ":") + byPosition := []string{socketPlain, socketSecure} + names := make([]string, count) + for i := range names { + if i < len(given) && (given[i] == socketPlain || given[i] == socketSecure) { + names[i] = given[i] + continue + } + if i < len(byPosition) { + names[i] = byPosition[i] + } + } + return names +} diff --git a/internal/entry/entry_test.go b/internal/entry/entry_test.go new file mode 100644 index 00000000..14ff7cdf --- /dev/null +++ b/internal/entry/entry_test.go @@ -0,0 +1,364 @@ +package entry + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "io" + "log/slog" + "math/big" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestParseMode(t *testing.T) { + for raw, want := range map[string]Mode{"off": Off, "file": File, "auto": Auto, " AUTO ": Auto} { + got, err := ParseMode(raw) + if err != nil { + t.Errorf("ParseMode(%q) = _, %v, want %q, nil", raw, err, want) + continue + } + if got != want { + t.Errorf("ParseMode(%q) = %q, want %q", raw, got, want) + } + } + for _, raw := range []string{"", "on", "acme", "tls"} { + if got, err := ParseMode(raw); err == nil { + t.Errorf("ParseMode(%q) = %q, nil, want an error", raw, got) + } + } +} + +func TestConfigCheckRejectsIncompleteModes(t *testing.T) { + handler := http.NotFoundHandler() + cases := map[string]Config{ + "no handler": {Mode: Off, Plain: ":80"}, + "no plain address": {Mode: Off, Handler: handler}, + "file without pair": {Mode: File, Plain: ":80", Secure: ":443", Handler: handler, + CertFile: "cert.pem"}, + "file without secure address": {Mode: File, Plain: ":80", Handler: handler, + CertFile: "cert.pem", KeyFile: "key.pem"}, + "auto without cache": {Mode: Auto, Plain: ":80", Secure: ":443", Handler: handler, + Hosts: func(context.Context, string) error { return nil }}, + "auto without hosts": {Mode: Auto, Plain: ":80", Secure: ":443", Handler: handler, + CacheDir: "certs"}, + "unknown mode": {Mode: Mode("on"), Plain: ":80", Handler: handler}, + } + for name, cfg := range cases { + if err := cfg.check(); err == nil { + t.Errorf("check() with %s = nil, want an error", name) + } + } + ok := Config{Mode: Off, Plain: ":80", Handler: handler} + if err := ok.check(); err != nil { + t.Errorf("check() with a complete off config = %v, want nil", err) + } +} + +func TestRedirectSecure(t *testing.T) { + cases := []struct { + secure string + host string + target string + want string + }{ + {":443", "example.com", "/a/b?c=d", "https://example.com/a/b?c=d"}, + {":443", "example.com:80", "/", "https://example.com/"}, + {"127.0.0.1:8443", "example.com:8080", "/x", "https://example.com:8443/x"}, + } + for _, c := range cases { + w := record(t, redirectSecure(c.secure), request(t, http.MethodGet, c.host, c.target)) + if got := w.Header.Get("Location"); got != c.want { + t.Errorf("redirectSecure(%q) Location for %q = %q, want %q", c.secure, c.host, got, c.want) + } + if w.StatusCode != http.StatusFound { + t.Errorf("redirectSecure(%q) status = %d, want %d", c.secure, w.StatusCode, http.StatusFound) + } + } + + w := record(t, redirectSecure(":443"), request(t, http.MethodPost, "example.com", "/")) + if w.StatusCode != http.StatusBadRequest { + t.Errorf("redirectSecure POST status = %d, want %d", w.StatusCode, http.StatusBadRequest) + } +} + +func TestSocketNames(t *testing.T) { + cases := []struct { + count int + raw string + want []string + }{ + {1, "", []string{"http"}}, + {2, "", []string{"http", "https"}}, + {2, "https:http", []string{"https", "http"}}, + {2, "unknown:https", []string{"http", "https"}}, + {3, "", []string{"http", "https", ""}}, + } + for _, c := range cases { + got := socketNames(c.count, c.raw) + if !slices.Equal(got, c.want) { + t.Errorf("socketNames(%d, %q) = %v, want %v", c.count, c.raw, got, c.want) + } + } +} + +func TestPrivileged(t *testing.T) { + for addr, want := range map[string]bool{ + ":80": true, ":443": true, "127.0.0.1:8080": false, ":1024": false, ":0": false, "nonsense": false, + } { + if got := privileged(addr); got != want { + t.Errorf("privileged(%q) = %t, want %t", addr, got, want) + } + } +} + +func TestCertKeeperReloadsAChangedPair(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "cert.pem") + keyFile := filepath.Join(dir, "key.pem") + first := writePair(t, certFile, keyFile, 1) + + keeper := &certKeeper{certFile: certFile, keyFile: keyFile, log: slog.Default()} + got, err := keeper.get(nil) + if err != nil { + t.Fatalf("get() = _, %v, want a certificate", err) + } + if got.Leaf.SerialNumber.Int64() != first { + t.Errorf("get() serial = %d, want %d", got.Leaf.SerialNumber.Int64(), first) + } + + second := writePair(t, certFile, keyFile, 2) + bump(t, certFile, keyFile) + got, err = keeper.get(nil) + if err != nil { + t.Fatalf("get() after renewal = _, %v, want a certificate", err) + } + if got.Leaf.SerialNumber.Int64() != second { + t.Errorf("get() serial after renewal = %d, want %d", got.Leaf.SerialNumber.Int64(), second) + } +} + +func TestCertKeeperKeepsThePairWhenReloadFails(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "cert.pem") + keyFile := filepath.Join(dir, "key.pem") + serial := writePair(t, certFile, keyFile, 7) + + keeper := &certKeeper{certFile: certFile, keyFile: keyFile, + log: slog.New(slog.NewTextHandler(io.Discard, nil))} + if _, err := keeper.get(nil); err != nil { + t.Fatalf("get() = _, %v, want a certificate", err) + } + + if err := os.WriteFile(certFile, []byte("half written\n"), 0o600); err != nil { + t.Fatal(err) + } + bump(t, certFile, keyFile) + got, err := keeper.get(nil) + if err != nil { + t.Fatalf("get() over a broken pair = _, %v, want the previous certificate", err) + } + if got.Leaf.SerialNumber.Int64() != serial { + t.Errorf("get() serial over a broken pair = %d, want %d", got.Leaf.SerialNumber.Int64(), serial) + } +} + +func TestCertKeeperFailsWhenTheFirstLoadFails(t *testing.T) { + dir := t.TempDir() + keeper := &certKeeper{certFile: filepath.Join(dir, "missing.pem"), + keyFile: filepath.Join(dir, "missing.key"), log: slog.Default()} + if _, err := keeper.reload(); err == nil { + t.Error("reload() over a missing pair = _, nil, want an error") + } +} + +func TestServeFileModeAnswersBothListeners(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "cert.pem") + keyFile := filepath.Join(dir, "key.pem") + writePair(t, certFile, keyFile, 3) + + plain, secure := freeAddr(t), freeAddr(t) + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Serve(ctx, Config{ + Mode: File, + Plain: plain, + Secure: secure, + CertFile: certFile, + KeyFile: keyFile, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "served") + }), + }) + }() + waitFor(t, secure) + + pool := x509.NewCertPool() + pem, err := os.ReadFile(certFile) + if err != nil { + t.Fatal(err) + } + pool.AppendCertsFromPEM(pem) + client := &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + body, status := fetch(t, client, "https://"+secure+"/") + if status != http.StatusOK || body != "served" { + t.Errorf("GET https = %d %q, want 200 \"served\"", status, body) + } + + _, status = fetch(t, client, "http://"+plain+"/") + if status != http.StatusFound { + t.Errorf("GET http = %d, want %d", status, http.StatusFound) + } + + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("Serve() = %v, want nil", err) + } + case <-time.After(20 * time.Second): + t.Error("Serve() did not return after the context was cancelled") + } +} + +func TestServeReportsABusyAddress(t *testing.T) { + held, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer held.Close() + + err = Serve(context.Background(), Config{ + Mode: Off, + Plain: held.Addr().String(), + Handler: http.NotFoundHandler(), + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err == nil { + t.Fatal("Serve() over a busy address = nil, want an error") + } + if !strings.Contains(err.Error(), "for http") { + t.Errorf("Serve() error = %q, want it to name the http listener", err) + } +} + +func request(t *testing.T, method, host, target string) *http.Request { + t.Helper() + r, err := http.NewRequest(method, "http://"+host+target, nil) + if err != nil { + t.Fatal(err) + } + r.Host = host + return r +} + +func record(t *testing.T, h http.Handler, r *http.Request) *http.Response { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec.Result() +} + +func writePair(t *testing.T, certFile, keyFile string, serial int64) int64 { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), + Subject: pkix.Name{CommonName: "pwikit test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.IPv6loopback}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + der8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + write(t, certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + write(t, keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der8})) + return serial +} + +func write(t *testing.T, name string, body []byte) { + t.Helper() + if err := os.WriteFile(name, body, 0o600); err != nil { + t.Fatal(err) + } +} + +func bump(t *testing.T, names ...string) { + t.Helper() + later := time.Now().Add(time.Minute) + for _, name := range names { + if err := os.Chtimes(name, later, later); err != nil { + t.Fatal(err) + } + } +} + +func freeAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := l.Addr().String() + l.Close() + return addr +} + +func waitFor(t *testing.T, addr string) { + t.Helper() + for range 200 { + conn, err := net.DialTimeout("tcp", addr, time.Second) + if err == nil { + conn.Close() + return + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("nothing accepted a connection on %s", addr) +} + +func fetch(t *testing.T, client *http.Client, target string) (string, int) { + t.Helper() + resp, err := client.Get(target) + if err != nil { + t.Fatalf("GET %s = %v", target, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + return string(body), resp.StatusCode +} diff --git a/internal/escape/escape.go b/internal/escape/escape.go new file mode 100644 index 00000000..5d91a01b --- /dev/null +++ b/internal/escape/escape.go @@ -0,0 +1,37 @@ +// Package escape holds the HTML and URL escapes these pages use, which the +// standard library spells differently. +package escape + +import "strings" + +// These pages spell ' as ' where html.EscapeString writes ', and the +// whole renderer is checked on byte-identical output. +var replacer = strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", +) + +func HTML(s string) string { return replacer.Replace(s) } + +const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~/" + +// URLQuote is urllib.parse.quote with its default safe="/". Neither +// url.PathEscape nor url.QueryEscape matches it. +func URLQuote(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if strings.IndexByte(unreserved, c) >= 0 { + b.WriteByte(c) + continue + } + const hex = "0123456789ABCDEF" + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0x0F]) + } + return b.String() +} diff --git a/internal/escape/escape_test.go b/internal/escape/escape_test.go new file mode 100644 index 00000000..84bb8df1 --- /dev/null +++ b/internal/escape/escape_test.go @@ -0,0 +1,37 @@ +package escape + +import "testing" + +func TestHTML(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"plain", "plain"}, + {"", "<b>"}, + {`"quoted"`, ""quoted""}, + {"it's", "it's"}, + {"a&b", "a&b"}, + {"<", "&lt;"}, + {"中文 ", "中文 <b>"}, + } + for _, c := range cases { + if got := HTML(c.in); got != c.want { + t.Errorf("HTML(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestURLQuoteKeepsPythonSafeSet(t *testing.T) { + cases := []struct{ in, want string }{ + {"abcXYZ019", "abcXYZ019"}, + {"_.-~/", "_.-~/"}, + {" ", "%20"}, + {":", "%3A"}, + {"&+,;=@$", "%26%2B%2C%3B%3D%40%24"}, + {"中", "%E4%B8%AD"}, + } + for _, c := range cases { + if got := URLQuote(c.in); got != c.want { + t.Errorf("URLQuote(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/escape/js.go b/internal/escape/js.go new file mode 100644 index 00000000..9f640c5c --- /dev/null +++ b/internal/escape/js.go @@ -0,0 +1,26 @@ +package escape + +import ( + "fmt" + "strings" +) + +const ( + jsSpecials = "\\'\"><&=-;`" + lineSep = '\u2028' + paragraphSep = '\u2029' +) + +// Go's template.JSEscapeString covers a different set of characters and spells +// them differently, so it cannot stand in here. +func JS(s string) string { + var b strings.Builder + for _, r := range s { + if r < 0x20 || r == lineSep || r == paragraphSep || strings.ContainsRune(jsSpecials, r) { + fmt.Fprintf(&b, "\\u%04X", r) + continue + } + b.WriteRune(r) + } + return b.String() +} diff --git a/internal/escape/js_test.go b/internal/escape/js_test.go new file mode 100644 index 00000000..ea76daac --- /dev/null +++ b/internal/escape/js_test.go @@ -0,0 +1,34 @@ +package escape + +import "testing" + +func unicodeEscape(hex string) string { return `\` + "u" + hex } + +func TestJS(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"plain", "plain"}, + {`\`, unicodeEscape("005C")}, + {"'", unicodeEscape("0027")}, + {`"`, unicodeEscape("0022")}, + {">", unicodeEscape("003E")}, + {"<", unicodeEscape("003C")}, + {"&", unicodeEscape("0026")}, + {"=", unicodeEscape("003D")}, + {"-", unicodeEscape("002D")}, + {";", unicodeEscape("003B")}, + {"`", unicodeEscape("0060")}, + {"\n", unicodeEscape("000A")}, + {"\x00", unicodeEscape("0000")}, + {string(rune(0x2028)), unicodeEscape("2028")}, + {string(rune(0x2029)), unicodeEscape("2029")}, + {"G-AB123", "G" + unicodeEscape("002D") + "AB123"}, + {"中文", "中文"}, + {"a b", "a b"}, + } + for _, c := range cases { + if got := JS(c.in); got != c.want { + t.Errorf("JS(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/expr/builtin.go b/internal/expr/builtin.go new file mode 100644 index 00000000..6bfc3f17 --- /dev/null +++ b/internal/expr/builtin.go @@ -0,0 +1,273 @@ +package expr + +import ( + "math" + "math/rand/v2" + "strings" +) + +var brokenTrig = map[string]bool{ + "sin": true, "cos": true, "tan": true, + "asin": true, "acos": true, "atan": true, +} + +func evalCall(n *callNode) (Value, error) { + args := make([]Value, 0, len(n.args)) + for _, a := range n.args { + v, err := eval(a) + if err != nil { + return None(), err + } + args = append(args, v) + } + + if brokenTrig[n.name] { + return None(), errType + } + + switch n.name { + case "min", "max": + return extremum(n.name, args) + case "abs": + return absOf(args) + case "round": + return roundOf(args) + case "ceil", "floor": + return ceilFloor(n.name, args) + case "div": + return floorDiv(args) + case "random": + return randomOf(args) + case "sqrt": + return sqrtOf(args) + case "pow": + return powOf(args) + case "unset": + return unsetOf(args) + case "len": + return lenOf(args) + case "lower", "upper": + return changeCase(n.name, args) + case "substr": + return substrOf(args) + } + return None(), errType +} + +func arity(args []Value, allowed ...int) error { + for _, n := range allowed { + if len(args) == n { + return nil + } + } + return errType +} + +func extremum(name string, args []Value) (Value, error) { + if len(args) < 2 { + return None(), errType + } + best := args[0] + for _, v := range args[1:] { + cmp, err := order(v, best) + if err != nil { + return None(), err + } + if (name == "min" && cmp < 0) || (name == "max" && cmp > 0) { + best = v + } + } + return best, nil +} + +func absOf(args []Value) (Value, error) { + if err := arity(args, 1); err != nil { + return None(), err + } + x := args[0] + if !x.numeric() { + return None(), errType + } + if x.Kind == KindFloat { + return FloatOf(math.Abs(x.Float)), nil + } + if n := x.toInt(); n < 0 { + return IntOf(-n), nil + } + return IntOf(x.toInt()), nil +} + +func roundOf(args []Value) (Value, error) { + if err := arity(args, 1, 2); err != nil { + return None(), err + } + x := args[0] + if !x.numeric() { + return None(), errType + } + var digits int64 + if len(args) == 2 { + if !args[1].integral() { + return None(), errType + } + digits = args[1].toInt() + } + + if x.integral() { + if digits >= 0 { + return IntOf(x.toInt()), nil + } + shift := math.Pow(10, float64(-digits)) + return IntOf(int64(math.RoundToEven(float64(x.toInt())/shift) * shift)), nil + } + shift := math.Pow(10, float64(digits)) + return FloatOf(math.RoundToEven(x.Float*shift) / shift), nil +} + +func ceilFloor(name string, args []Value) (Value, error) { + if err := arity(args, 1); err != nil { + return None(), err + } + x := args[0] + if !x.numeric() { + return None(), errType + } + if x.integral() { + return IntOf(x.toInt()), nil + } + if name == "ceil" { + return IntOf(int64(math.Ceil(x.Float))), nil + } + return IntOf(int64(math.Floor(x.Float))), nil +} + +func floorDiv(args []Value) (Value, error) { + if err := arity(args, 2); err != nil { + return None(), err + } + x, y := args[0], args[1] + if !x.numeric() || !y.numeric() { + return None(), errType + } + if y.toFloat() == 0 { + return None(), errType + } + if x.integral() && y.integral() { + a, b := x.toInt(), y.toInt() + q := a / b + if (a%b != 0) && ((a < 0) != (b < 0)) { + q-- + } + return IntOf(q), nil + } + return FloatOf(math.Floor(x.toFloat() / y.toFloat())), nil +} + +func randomOf(args []Value) (Value, error) { + if err := arity(args, 2); err != nil { + return None(), err + } + if !args[0].integral() || !args[1].integral() { + return None(), errType + } + low, high := args[0].toInt(), args[1].toInt() + if low > high { + return None(), errType + } + // The width is worked out unsigned because the full int64 range overflows it. + span := uint64(high) - uint64(low) + 1 + if span == 0 { + return IntOf(int64(rand.Uint64())), nil + } + return IntOf(low + int64(rand.Uint64N(span))), nil +} + +func sqrtOf(args []Value) (Value, error) { + if err := arity(args, 1); err != nil { + return None(), err + } + if !args[0].numeric() { + return None(), errType + } + f := args[0].toFloat() + if f < 0 { + return None(), errType + } + return FloatOf(math.Sqrt(f)), nil +} + +func powOf(args []Value) (Value, error) { + if err := arity(args, 2); err != nil { + return None(), err + } + if !args[0].numeric() || !args[1].numeric() { + return None(), errType + } + return FloatOf(math.Pow(args[0].toFloat(), args[1].toFloat())), nil +} + +func unsetOf(args []Value) (Value, error) { + if err := arity(args, 1); err != nil { + return None(), err + } + s := args[0].Text() + return BoolOf(strings.HasPrefix(s, "%%") && strings.HasSuffix(s, "%%")), nil +} + +func lenOf(args []Value) (Value, error) { + if err := arity(args, 1); err != nil { + return None(), err + } + if args[0].Kind != KindStr { + return None(), errType + } + return IntOf(int64(len([]rune(args[0].Str)))), nil +} + +func changeCase(name string, args []Value) (Value, error) { + if err := arity(args, 1); err != nil { + return None(), err + } + if args[0].Kind != KindStr { + return None(), errType + } + if name == "lower" { + return StrOf(strings.ToLower(args[0].Str)), nil + } + return StrOf(strings.ToUpper(args[0].Str)), nil +} + +func substrOf(args []Value) (Value, error) { + if err := arity(args, 2, 3); err != nil { + return None(), err + } + if args[0].Kind != KindStr || !args[1].integral() { + return None(), errType + } + runes := []rune(args[0].Str) + start := clampIndex(args[1].toInt(), len(runes)) + stop := len(runes) + if len(args) == 3 { + if !args[2].integral() { + return None(), errType + } + stop = clampIndex(args[2].toInt(), len(runes)) + } + if stop <= start { + return StrOf(""), nil + } + return StrOf(string(runes[start:stop])), nil +} + +func clampIndex(i int64, n int) int { + if i < 0 { + i += int64(n) + if i < 0 { + return 0 + } + } + if i > int64(n) { + return n + } + return int(i) +} diff --git a/internal/expr/eval.go b/internal/expr/eval.go new file mode 100644 index 00000000..c65f531d --- /dev/null +++ b/internal/expr/eval.go @@ -0,0 +1,244 @@ +package expr + +import ( + "strings" +) + +// Page text reaches here after variables are substituted, so neither the +// expression nor the strings it builds are bounded by the page size. +const ( + maxSource = 16 << 10 + maxString = 64 << 10 +) + +func Evaluate(src string) Value { + if len(src) > maxSource { + return None() + } + n, err := parse(src) + if err != nil { + return None() + } + v, err := eval(n) + if err != nil { + return None() + } + return v +} + +func eval(n node) (Value, error) { + switch n := n.(type) { + case *constNode: + return n.v, nil + case *unaryNode: + return evalUnary(n) + case *binNode: + return evalBinary(n) + case *cmpNode: + return evalCompare(n) + case *boolNode: + return evalBool(n) + case *callNode: + return evalCall(n) + } + return None(), errType +} + +func evalUnary(n *unaryNode) (Value, error) { + if n.op != "-" { + return None(), errType + } + x, err := eval(n.x) + if err != nil { + return None(), err + } + if !x.numeric() { + return None(), errType + } + if x.Kind == KindFloat { + return FloatOf(-x.Float), nil + } + return IntOf(-x.toInt()), nil +} + +func evalBinary(n *binNode) (Value, error) { + x, err := eval(n.x) + if err != nil { + return None(), err + } + y, err := eval(n.y) + if err != nil { + return None(), err + } + + switch n.op { + case "+": + if x.Kind == KindStr && y.Kind == KindStr { + if len(x.Str)+len(y.Str) > maxString { + return None(), errType + } + return StrOf(x.Str + y.Str), nil + } + return arith(x, y, func(a, b int64) int64 { return a + b }, func(a, b float64) float64 { return a + b }) + case "-": + return arith(x, y, func(a, b int64) int64 { return a - b }, func(a, b float64) float64 { return a - b }) + case "*": + if repeated, ok, err := repeat(x, y); ok { + return repeated, err + } + return arith(x, y, func(a, b int64) int64 { return a * b }, func(a, b float64) float64 { return a * b }) + case "/": + if !x.numeric() || !y.numeric() { + return None(), errType + } + if y.toFloat() == 0 { + return None(), errType + } + return FloatOf(x.toFloat() / y.toFloat()), nil + case "^": + if !x.integral() || !y.integral() { + return None(), errType + } + if x.Kind == KindBool && y.Kind == KindBool { + return BoolOf(x.Bool != y.Bool), nil + } + return IntOf(x.toInt() ^ y.toInt()), nil + } + return None(), errType +} + +func arith(x, y Value, ints func(a, b int64) int64, floats func(a, b float64) float64) (Value, error) { + if !x.numeric() || !y.numeric() { + return None(), errType + } + if x.Kind == KindFloat || y.Kind == KindFloat { + return FloatOf(floats(x.toFloat(), y.toFloat())), nil + } + return IntOf(ints(x.toInt(), y.toInt())), nil +} + +func repeat(x, y Value) (Value, bool, error) { + str, count := x, y + if str.Kind != KindStr { + str, count = y, x + } + if str.Kind != KindStr || !count.integral() { + return None(), false, nil + } + n := count.toInt() + if n <= 0 || str.Str == "" { + return StrOf(""), true, nil + } + if n > int64(maxString/len(str.Str)) { + return None(), true, errType + } + return StrOf(strings.Repeat(str.Str, int(n))), true, nil +} + +func evalCompare(n *cmpNode) (Value, error) { + for i, op := range n.ops { + left, err := eval(n.items[i]) + if err != nil { + return None(), err + } + right, err := eval(n.items[i+1]) + if err != nil { + return None(), err + } + ok, err := compare(left, op, right) + if err != nil { + return None(), err + } + if !ok { + return BoolOf(false), nil + } + } + return BoolOf(true), nil +} + +func compare(x Value, op string, y Value) (bool, error) { + switch op { + case "==": + return equal(x, y), nil + case "!=": + return !equal(x, y), nil + } + cmp, err := order(x, y) + if err != nil { + return false, err + } + switch op { + case "<": + return cmp < 0, nil + case "<=": + return cmp <= 0, nil + case ">": + return cmp > 0, nil + case ">=": + return cmp >= 0, nil + } + return false, errType +} + +func equal(x, y Value) bool { + switch { + case x.integral() && y.integral(): + return x.toInt() == y.toInt() + case x.numeric() && y.numeric(): + return x.toFloat() == y.toFloat() + case x.Kind == KindStr && y.Kind == KindStr: + return x.Str == y.Str + case x.Kind == KindNone && y.Kind == KindNone: + return true + } + return false +} + +func order(x, y Value) (int, error) { + switch { + case x.integral() && y.integral(): + return cmpInt(x.toInt(), y.toInt()), nil + case x.numeric() && y.numeric(): + return cmpFloat(x.toFloat(), y.toFloat()), nil + case x.Kind == KindStr && y.Kind == KindStr: + return strings.Compare(x.Str, y.Str), nil + } + return 0, errType +} + +func cmpInt(a, b int64) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +func cmpFloat(a, b float64) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + } + return 0 +} + +func evalBool(n *boolNode) (Value, error) { + truthy := 0 + for _, item := range n.items { + v, err := eval(item) + if err != nil { + return None(), err + } + if v.Truthy() { + truthy++ + } + } + if n.op == "and" { + return BoolOf(truthy == len(n.items)), nil + } + return BoolOf(truthy > 0), nil +} diff --git a/internal/expr/expr_test.go b/internal/expr/expr_test.go new file mode 100644 index 00000000..d0917938 --- /dev/null +++ b/internal/expr/expr_test.go @@ -0,0 +1,353 @@ +package expr + +import ( + "strings" + "testing" +) + +func check(t *testing.T, src string, want Value) { + t.Helper() + got := Evaluate(src) + if got != want { + t.Errorf("Evaluate(%q) = %+v, want %+v", src, got, want) + } +} + +func TestEvaluateLiterals(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"1", IntOf(1)}, + {"-1", IntOf(-1)}, + {"1.5", FloatOf(1.5)}, + {".5", FloatOf(0.5)}, + {"1e3", FloatOf(1000)}, + {"1e-3", FloatOf(0.001)}, + {"'abc'", StrOf("abc")}, + {`"abc"`, StrOf("abc")}, + {`'a\nb'`, StrOf("a\nb")}, + {"True", BoolOf(true)}, + {"False", BoolOf(false)}, + {"None", None()}, + {" 1 ", IntOf(1)}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateArithmetic(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"1 + 2", IntOf(3)}, + {"5 - 8", IntOf(-3)}, + {"3 * 4", IntOf(12)}, + {"1 + 2 * 3", IntOf(7)}, + {"(1 + 2) * 3", IntOf(9)}, + {"1.5 + 1", FloatOf(2.5)}, + {"True + True", IntOf(2)}, + {"-True", IntOf(-1)}, + {"'a' + 'b'", StrOf("ab")}, + {"'ab' * 3", StrOf("ababab")}, + {"3 * 'ab'", StrOf("ababab")}, + {"'ab' * 0", StrOf("")}, + {"'a' + 1", None()}, + {"-'a'", None()}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateDivisionIsAlwaysFloat(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"6 / 3", FloatOf(2)}, + {"7 / 2", FloatOf(3.5)}, + {"1 / 0", None()}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateCaretIsXorNotPower(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"2 ^ 3", IntOf(1)}, + {"1 ^ 2 + 3", IntOf(4)}, + {"True ^ False", BoolOf(true)}, + {"True ^ 1", IntOf(0)}, + {"1.0 ^ 2", None()}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateComparison(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"1 == 1", BoolOf(true)}, + {"1 != 2", BoolOf(true)}, + {"1 < 2 < 3", BoolOf(true)}, + {"1 < 2 > 5", BoolOf(false)}, + {"'a' < 'b'", BoolOf(true)}, + {"1 == True", BoolOf(true)}, + {"1 == '1'", BoolOf(false)}, + {"None == None", BoolOf(true)}, + {"None == 0", BoolOf(false)}, + {"1 < 'a'", None()}, + {"1 <= 1.0", BoolOf(true)}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateBoolOpsReturnBoolNotOperand(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"1 and 2", BoolOf(true)}, + {"0 and 2", BoolOf(false)}, + {"0 or 2", BoolOf(true)}, + {"0 or ''", BoolOf(false)}, + {"'x' or 0", BoolOf(true)}, + {"1 or 0 and 0", BoolOf(true)}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateUnsupportedUnary(t *testing.T) { + for _, src := range []string{"not True", "+1", "not 0"} { + t.Run(src, func(t *testing.T) { check(t, src, None()) }) + } +} + +func TestEvaluateNumericBuiltins(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"min(3, 1, 2)", IntOf(1)}, + {"max(3, 1, 2)", IntOf(3)}, + {"min(5)", None()}, + {"min(1, 'a')", None()}, + {"abs(-3)", IntOf(3)}, + {"abs(-3.5)", FloatOf(3.5)}, + {"abs('a')", None()}, + {"ceil(1.2)", IntOf(2)}, + {"floor(1.8)", IntOf(1)}, + {"ceil(2)", IntOf(2)}, + {"div(7, 2)", IntOf(3)}, + {"div(-7, 2)", IntOf(-4)}, + {"div(7, 0)", None()}, + {"sqrt(9)", FloatOf(3)}, + {"sqrt(-1)", None()}, + {"pow(2, 3)", FloatOf(8)}, + {"pow(2, 0.5)", FloatOf(1.4142135623730951)}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateRoundUsesBankersRoundingAndReturnsFloat(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"round(2.5)", FloatOf(2)}, + {"round(3.5)", FloatOf(4)}, + {"round(-2.5)", FloatOf(-2)}, + {"round(2.34, 1)", FloatOf(2.3)}, + {"round(7)", IntOf(7)}, + {"round('a')", None()}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateTrigAlwaysFails(t *testing.T) { + for _, src := range []string{"sin(0)", "cos(0)", "tan(0)", "asin(0)", "acos(1)", "atan(0)"} { + t.Run(src, func(t *testing.T) { check(t, src, None()) }) + } +} + +func TestEvaluateStringBuiltins(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"len('abc')", IntOf(3)}, + {"len('中文')", IntOf(2)}, + {"len(3)", None()}, + {"lower('ABC')", StrOf("abc")}, + {"upper('abc')", StrOf("ABC")}, + {"upper(3)", None()}, + {"substr('abcdef', 1, 3)", StrOf("bc")}, + {"substr('abcdef', 2)", StrOf("cdef")}, + {"substr('abcdef', -2)", StrOf("ef")}, + {"substr('abcdef', 4, 2)", StrOf("")}, + {"substr('abcdef', 0, 99)", StrOf("abcdef")}, + {"substr('中文字', 1, 2)", StrOf("文")}, + {"substr(3, 1)", None()}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateUnset(t *testing.T) { + tests := []struct { + src string + want Value + }{ + {"unset('%%title%%')", BoolOf(true)}, + {"unset('title')", BoolOf(false)}, + {"unset('%%')", BoolOf(true)}, + {"unset(1)", BoolOf(false)}, + } + for _, tt := range tests { + t.Run(tt.src, func(t *testing.T) { check(t, tt.src, tt.want) }) + } +} + +func TestEvaluateFunctionNamesAreCaseInsensitive(t *testing.T) { + check(t, "MIN(1, 2)", IntOf(1)) + check(t, "Upper('a')", StrOf("A")) +} + +func TestEvaluateConstantNamesAreCaseSensitive(t *testing.T) { + for _, src := range []string{"true", "false", "none"} { + t.Run(src, func(t *testing.T) { check(t, src, None()) }) + } +} + +func TestEvaluateMalformedInput(t *testing.T) { + tests := []string{ + "", + " ", + "1 +", + "1 2", + "(1", + "1)", + "foo", + "foo(1)", + "1 % 2", + "7 // 2", + "'unterminated", + "1, 2", + "min(1,)", + } + for _, src := range tests { + t.Run(src, func(t *testing.T) { check(t, src, None()) }) + } +} + +func TestEvaluateRandomStaysInRange(t *testing.T) { + for range 50 { + got := Evaluate("random(1, 3)") + if got.Kind != KindInt || got.Int < 1 || got.Int > 3 { + t.Fatalf("Evaluate(\"random(1, 3)\") = %+v, want an int in 1..3", got) + } + } + check(t, "random(3, 1)", None()) +} + +func TestText(t *testing.T) { + tests := []struct { + in Value + want string + }{ + {None(), "None"}, + {BoolOf(true), "True"}, + {BoolOf(false), "False"}, + {IntOf(-7), "-7"}, + {FloatOf(1), "1.0"}, + {FloatOf(1.5), "1.5"}, + {StrOf("x"), "x"}, + } + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + if got := tt.in.Text(); got != tt.want { + t.Errorf("Text(%+v) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestTruthy(t *testing.T) { + tests := []struct { + in Value + want bool + }{ + {None(), false}, + {BoolOf(false), false}, + {IntOf(0), false}, + {IntOf(1), true}, + {FloatOf(0), false}, + {StrOf(""), false}, + {StrOf("0"), true}, + } + for _, tt := range tests { + t.Run(tt.in.Text(), func(t *testing.T) { + if got := tt.in.Truthy(); got != tt.want { + t.Errorf("Truthy(%+v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +func TestEvaluateRandomAcrossTheWholeIntRange(t *testing.T) { + for _, src := range []string{ + "random(0, 9223372036854775807)", + "random(-9223372036854775807 - 1, 9223372036854775807)", + "random(-5, -5)", + } { + if got := Evaluate(src); got.Kind != KindInt { + t.Errorf("Evaluate(%q).Kind = %v, want %v", src, got.Kind, KindInt) + } + } + if got := Evaluate("random(-5, -5)"); got != IntOf(-5) { + t.Errorf("Evaluate(%q) = %+v, want %+v", "random(-5, -5)", got, IntOf(-5)) + } +} + +func TestEvaluateRepeatBeyondTheStringLimit(t *testing.T) { + check(t, `"a" * 100000000000`, None()) + if got := Evaluate(`"ab" * 32769`); got.Kind != KindNone { + t.Errorf("Evaluate(%q).Kind = %v, want %v", `"ab" * 32769`, got.Kind, KindNone) + } + if got := Evaluate(`"ab" * 32768`); len(got.Str) != 65536 { + t.Errorf("len(Evaluate(%q).Str) = %d, want %d", `"ab" * 32768`, len(got.Str), 65536) + } + check(t, `"" * 100000000000`, StrOf("")) +} + +func TestEvaluateConcatBeyondTheStringLimit(t *testing.T) { + if got := Evaluate(`"a" * 40000 + "a" * 40000`); got.Kind != KindNone { + t.Errorf("Evaluate(%q).Kind = %v, want %v", `"a" * 40000 + "a" * 40000`, got.Kind, KindNone) + } +} + +func TestEvaluateSourceBeyondTheLimit(t *testing.T) { + long := "1" + strings.Repeat(" + 1", 4096) + check(t, long, None()) + short := "1" + strings.Repeat(" + 1", 1024) + check(t, short, IntOf(1025)) +} diff --git a/internal/expr/parse.go b/internal/expr/parse.go new file mode 100644 index 00000000..2edd096e --- /dev/null +++ b/internal/expr/parse.go @@ -0,0 +1,394 @@ +package expr + +import ( + "errors" + "strconv" + "strings" +) + +var errSyntax = errors.New("expr: syntax error") + +type node interface{} + +type constNode struct{ v Value } + +type unaryNode struct { + op string + x node +} + +type binNode struct { + op string + x, y node +} + +type cmpNode struct { + ops []string + items []node +} + +type boolNode struct { + op string + items []node +} + +type callNode struct { + name string + args []node +} + +type tokenKind uint8 + +const ( + tokEOF tokenKind = iota + tokNumber + tokString + tokIdent + tokOp +) + +type token struct { + kind tokenKind + text string + v Value +} + +var operators = []string{"==", "!=", "<=", ">=", "<", ">", "+", "-", "*", "/", "^", "(", ")", ","} + +const ( + quoteSingle = '\'' + quoteDouble = '"' + backslash = '\\' +) + +func lex(src string) ([]token, error) { + var out []token + i := 0 + for i < len(src) { + c := src[i] + switch { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i++ + case c == '#': + for i < len(src) && src[i] != '\n' { + i++ + } + case c == quoteSingle || c == quoteDouble: + s, next, err := lexString(src, i) + if err != nil { + return nil, err + } + out = append(out, token{kind: tokString, v: StrOf(s)}) + i = next + case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])): + v, next, err := lexNumber(src, i) + if err != nil { + return nil, err + } + out = append(out, token{kind: tokNumber, v: v}) + i = next + case isIdentStart(c): + j := i + for j < len(src) && isIdentPart(src[j]) { + j++ + } + out = append(out, token{kind: tokIdent, text: src[i:j]}) + i = j + default: + op := matchOperator(src[i:]) + if op == "" { + return nil, errSyntax + } + out = append(out, token{kind: tokOp, text: op}) + i += len(op) + } + } + return append(out, token{kind: tokEOF}), nil +} + +func matchOperator(s string) string { + for _, op := range operators { + if strings.HasPrefix(s, op) { + return op + } + } + return "" +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func isIdentStart(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c >= 0x80 +} + +func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) } + +func lexString(src string, start int) (string, int, error) { + quote := src[start] + var b strings.Builder + i := start + 1 + for i < len(src) { + switch src[i] { + case quote: + return b.String(), i + 1, nil + case backslash: + if i+1 >= len(src) { + return "", 0, errSyntax + } + switch src[i+1] { + case 'n': + b.WriteByte('\n') + case 't': + b.WriteByte('\t') + case 'r': + b.WriteByte('\r') + default: + b.WriteByte(src[i+1]) + } + i += 2 + default: + b.WriteByte(src[i]) + i++ + } + } + return "", 0, errSyntax +} + +func lexNumber(src string, start int) (Value, int, error) { + i := start + isFloat := false + for i < len(src) { + c := src[i] + if isDigit(c) { + i++ + continue + } + if c == '.' && !isFloat { + isFloat = true + i++ + continue + } + if (c == 'e' || c == 'E') && i > start && exponentFollows(src, i) { + isFloat = true + i += 2 + continue + } + break + } + + text := src[start:i] + if isFloat { + f, err := strconv.ParseFloat(text, 64) + if err != nil { + return None(), 0, errSyntax + } + return FloatOf(f), i, nil + } + n, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return None(), 0, errSyntax + } + return IntOf(n), i, nil +} + +func exponentFollows(src string, i int) bool { + if i+1 >= len(src) { + return false + } + if isDigit(src[i+1]) { + return true + } + return (src[i+1] == '+' || src[i+1] == '-') && i+2 < len(src) && isDigit(src[i+2]) +} + +type parser struct { + tokens []token + pos int +} + +func parse(src string) (node, error) { + tokens, err := lex(src) + if err != nil { + return nil, err + } + p := &parser{tokens: tokens} + n, err := p.parseOr() + if err != nil { + return nil, err + } + if p.peek().kind != tokEOF { + return nil, errSyntax + } + return n, nil +} + +func (p *parser) peek() token { return p.tokens[p.pos] } + +func (p *parser) acceptOp(ops ...string) (string, bool) { + t := p.peek() + if t.kind != tokOp { + return "", false + } + for _, op := range ops { + if t.text == op { + p.pos++ + return op, true + } + } + return "", false +} + +func (p *parser) acceptWord(word string) bool { + if t := p.peek(); t.kind == tokIdent && t.text == word { + p.pos++ + return true + } + return false +} + +func (p *parser) parseOr() (node, error) { return p.parseBool("or", p.parseAnd) } +func (p *parser) parseAnd() (node, error) { return p.parseBool("and", p.parseCompare) } + +func (p *parser) parseBool(word string, next func() (node, error)) (node, error) { + first, err := next() + if err != nil { + return nil, err + } + items := []node{first} + for p.acceptWord(word) { + operand, err := next() + if err != nil { + return nil, err + } + items = append(items, operand) + } + if len(items) == 1 { + return first, nil + } + return &boolNode{op: word, items: items}, nil +} + +func (p *parser) parseCompare() (node, error) { + first, err := p.parseXor() + if err != nil { + return nil, err + } + var ops []string + items := []node{first} + for { + op, ok := p.acceptOp("==", "!=", "<=", ">=", "<", ">") + if !ok { + break + } + operand, err := p.parseXor() + if err != nil { + return nil, err + } + ops = append(ops, op) + items = append(items, operand) + } + if len(ops) == 0 { + return first, nil + } + return &cmpNode{ops: ops, items: items}, nil +} + +func (p *parser) parseXor() (node, error) { return p.parseBinary(p.parseAdd, "^") } +func (p *parser) parseAdd() (node, error) { return p.parseBinary(p.parseMul, "+", "-") } +func (p *parser) parseMul() (node, error) { return p.parseBinary(p.parseUnary, "*", "/") } + +func (p *parser) parseBinary(next func() (node, error), ops ...string) (node, error) { + left, err := next() + if err != nil { + return nil, err + } + for { + op, ok := p.acceptOp(ops...) + if !ok { + return left, nil + } + right, err := next() + if err != nil { + return nil, err + } + left = &binNode{op: op, x: left, y: right} + } +} + +func (p *parser) parseUnary() (node, error) { + if op, ok := p.acceptOp("-", "+"); ok { + operand, err := p.parseUnary() + if err != nil { + return nil, err + } + return &unaryNode{op: op, x: operand}, nil + } + if p.acceptWord("not") { + operand, err := p.parseUnary() + if err != nil { + return nil, err + } + return &unaryNode{op: "not", x: operand}, nil + } + return p.parseAtom() +} + +func (p *parser) parseAtom() (node, error) { + t := p.peek() + switch t.kind { + case tokNumber, tokString: + p.pos++ + return &constNode{v: t.v}, nil + case tokIdent: + p.pos++ + switch t.text { + case "True": + return &constNode{v: BoolOf(true)}, nil + case "False": + return &constNode{v: BoolOf(false)}, nil + case "None": + return &constNode{v: None()}, nil + } + if _, ok := p.acceptOp("("); !ok { + return nil, errSyntax + } + args, err := p.parseArgs() + if err != nil { + return nil, err + } + return &callNode{name: strings.ToLower(t.text), args: args}, nil + case tokOp: + if _, ok := p.acceptOp("("); ok { + inner, err := p.parseOr() + if err != nil { + return nil, err + } + if _, ok := p.acceptOp(")"); !ok { + return nil, errSyntax + } + return inner, nil + } + } + return nil, errSyntax +} + +func (p *parser) parseArgs() ([]node, error) { + var args []node + if _, ok := p.acceptOp(")"); ok { + return args, nil + } + for { + arg, err := p.parseOr() + if err != nil { + return nil, err + } + args = append(args, arg) + if _, ok := p.acceptOp(","); ok { + continue + } + if _, ok := p.acceptOp(")"); ok { + return args, nil + } + return nil, errSyntax + } +} diff --git a/internal/expr/value.go b/internal/expr/value.go new file mode 100644 index 00000000..153d93c2 --- /dev/null +++ b/internal/expr/value.go @@ -0,0 +1,106 @@ +package expr + +import ( + "errors" + "math" + "strconv" +) + +type Kind uint8 + +const ( + KindNone Kind = iota + KindBool + KindInt + KindFloat + KindStr +) + +type Value struct { + Kind Kind + Bool bool + Int int64 + Float float64 + Str string +} + +var errType = errors.New("expr: unsupported operand types") + +func None() Value { return Value{Kind: KindNone} } +func BoolOf(b bool) Value { return Value{Kind: KindBool, Bool: b} } +func IntOf(i int64) Value { return Value{Kind: KindInt, Int: i} } +func FloatOf(f float64) Value { return Value{Kind: KindFloat, Float: f} } +func StrOf(s string) Value { return Value{Kind: KindStr, Str: s} } + +func (v Value) numeric() bool { + return v.Kind == KindBool || v.Kind == KindInt || v.Kind == KindFloat +} + +func (v Value) integral() bool { + return v.Kind == KindBool || v.Kind == KindInt +} + +func (v Value) toInt() int64 { + switch v.Kind { + case KindBool: + if v.Bool { + return 1 + } + return 0 + case KindInt: + return v.Int + } + return 0 +} + +func (v Value) toFloat() float64 { + if v.Kind == KindFloat { + return v.Float + } + return float64(v.toInt()) +} + +func (v Value) Truthy() bool { + switch v.Kind { + case KindBool: + return v.Bool + case KindInt: + return v.Int != 0 + case KindFloat: + return v.Float != 0 + case KindStr: + return v.Str != "" + } + return false +} + +func (v Value) Text() string { + switch v.Kind { + case KindBool: + if v.Bool { + return "True" + } + return "False" + case KindInt: + return strconv.FormatInt(v.Int, 10) + case KindFloat: + if math.IsInf(v.Float, 1) { + return "inf" + } + if math.IsInf(v.Float, -1) { + return "-inf" + } + if math.IsNaN(v.Float) { + return "nan" + } + if v.Float == math.Trunc(v.Float) && math.Abs(v.Float) < 1e16 { + return strconv.FormatFloat(v.Float, 'f', 1, 64) + } + return strconv.FormatFloat(v.Float, 'g', -1, 64) + case KindStr: + return v.Str + } + return "None" +} + +func (v Value) AsInt() int64 { return v.toInt() } diff --git a/internal/form/form.go b/internal/form/form.go new file mode 100644 index 00000000..383afe24 --- /dev/null +++ b/internal/form/form.go @@ -0,0 +1,233 @@ +// Package form answers what a data form category stores and how one stored +// value reaches the page. +package form + +import ( + "fmt" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + TypeText = "text" + TypeWiki = "wiki" + TypeSelect = "select" + TypeCheckbox = "checkbox" + TypeStatic = "static" + TypeHidden = "hidden" +) + +type Option struct { + Key string + Label string +} + +type Field struct { + Name string + Type string + Label string + Hint string + Default string + Value string + Options []Option +} + +type Definition struct { + Fields []Field +} + +var blockPattern = regexp.MustCompile(`(?is)\[\[\s*form\s*]](.*?)\[\[\s*/\s*form\s*]]`) + +// A category template carries the definition for this package, not for the +// reader. +func Strip(source string) string { + return blockPattern.ReplaceAllString(source, "") +} + +func Parse(source string) (*Definition, bool, error) { + match := blockPattern.FindStringSubmatch(source) + if match == nil { + return nil, false, nil + } + def, err := parseDefinition(match[1]) + if err != nil { + return nil, true, err + } + return def, true, nil +} + +func parseDefinition(body string) (*Definition, error) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(body), &root); err != nil { + return nil, fmt.Errorf("parse form definition: %w", err) + } + mapping := documentMapping(&root) + if mapping == nil { + return &Definition{}, nil + } + fields := valueFor(mapping, "fields") + if fields == nil || fields.Kind != yaml.MappingNode { + return &Definition{}, nil + } + + out := &Definition{} + for i := 0; i+1 < len(fields.Content); i += 2 { + field := Field{Name: fields.Content[i].Value, Type: TypeText} + if body := fields.Content[i+1]; body.Kind == yaml.MappingNode { + readField(&field, body) + } + out.Fields = append(out.Fields, field) + } + return out, nil +} + +func readField(field *Field, body *yaml.Node) { + for i := 0; i+1 < len(body.Content); i += 2 { + key := strings.ToLower(strings.TrimSpace(body.Content[i].Value)) + value := body.Content[i+1] + switch key { + case "type": + field.Type = strings.ToLower(strings.TrimSpace(value.Value)) + case "label": + field.Label = value.Value + case "hint": + field.Hint = value.Value + case "default": + field.Default = value.Value + case "value": + field.Value = value.Value + case "values": + field.Options = readOptions(value) + } + } +} + +// Every option is read as the scalar's text, which keeps a key of 08 out of +// octal and one of Yes out of boolean. +func readOptions(node *yaml.Node) []Option { + if node.Kind != yaml.MappingNode { + return nil + } + out := make([]Option, 0, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + out = append(out, Option{Key: node.Content[i].Value, Label: node.Content[i+1].Value}) + } + return out +} + +func ParseData(source string) (map[string]string, error) { + var root yaml.Node + if err := yaml.Unmarshal([]byte(source), &root); err != nil { + return nil, fmt.Errorf("parse form data: %w", err) + } + mapping := documentMapping(&root) + if mapping == nil { + return map[string]string{}, nil + } + out := make(map[string]string, len(mapping.Content)/2) + for i := 0; i+1 < len(mapping.Content); i += 2 { + if value := mapping.Content[i+1]; value.Kind == yaml.ScalarNode { + out[mapping.Content[i].Value] = value.Value + } + } + return out, nil +} + +func documentMapping(root *yaml.Node) *yaml.Node { + node := root + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + node = node.Content[0] + } + if node.Kind != yaml.MappingNode { + return nil + } + return node +} + +func valueFor(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if strings.EqualFold(strings.TrimSpace(mapping.Content[i].Value), key) { + return mapping.Content[i+1] + } + } + return nil +} + +// Field matches without regard to case because a module argument reaches the +// query lowercased while the definition keeps whatever the author typed. +func (d *Definition) Field(name string) (*Field, bool) { + if d == nil { + return nil, false + } + for i := range d.Fields { + if strings.EqualFold(d.Fields[i].Name, name) { + return &d.Fields[i], true + } + } + return nil, false +} + +func (d *Definition) Raw(values map[string]string, name string) (string, bool) { + field, ok := d.Field(name) + if !ok { + return "", false + } + if field.Type == TypeStatic || field.Type == TypeHidden { + return field.Value, true + } + if stored, ok := values[field.Name]; ok { + return stored, true + } + return field.Default, true +} + +func (d *Definition) Data(values map[string]string, name string) (string, bool) { + field, ok := d.Field(name) + if !ok { + return "", false + } + raw, _ := d.Raw(values, name) + switch field.Type { + case TypeWiki, TypeStatic: + return raw, true + case TypeSelect: + for _, option := range field.Options { + if option.Key == raw { + // The label comes from the template rather than from whoever + // filled the page in, so it keeps its markup. + return option.Label, true + } + } + return EscapeMarkup(raw), true + } + return EscapeMarkup(raw), true +} + +func (d *Definition) Label(name string) (string, bool) { + field, ok := d.Field(name) + if !ok { + return "", false + } + return field.Label, true +} + +func (d *Definition) Hint(name string) (string, bool) { + field, ok := d.Field(name) + if !ok { + return "", false + } + return field.Hint, true +} + +// A value is substituted into the source before the renderer sees it, so a +// field that promises no wiki syntax has to carry its own escape. +func EscapeMarkup(value string) string { + if value == "" { + return "" + } + // A closing @@ inside the value would end the span early. Reopening around + // a literal pair puts it back as text. + return "@@" + strings.ReplaceAll(value, "@@", strings.Repeat("@", 10)) + "@@" +} diff --git a/internal/form/form_test.go b/internal/form/form_test.go new file mode 100644 index 00000000..96f1aeb5 --- /dev/null +++ b/internal/form/form_test.go @@ -0,0 +1,272 @@ +package form + +import "testing" + +const template = `**[[[notice|back]]]** +type: %%form_raw{NoticeType}%% + +==== + +[[form]] +fields: + NoticeBody: + type: wiki + label: "body" + hint: "wiki syntax allowed" + minLength: 1 + NoticeType: + type: select + label: kind + values: + normal: plain + important: "*" + default : normal + PinnedNotice: + type: checkbox + label: pinned + default: 0 + Note: + label: note +[[/form]] +` + +func parsed(t *testing.T) *Definition { + t.Helper() + def, found, err := Parse(template) + if err != nil { + t.Fatalf("Parse() err = %v, want nil", err) + } + if !found { + t.Fatal("Parse() found = false, want true") + } + return def +} + +func TestParseReadsFieldsInOrder(t *testing.T) { + def := parsed(t) + + want := []string{"NoticeBody", "NoticeType", "PinnedNotice", "Note"} + if len(def.Fields) != len(want) { + t.Fatalf("len(Fields) = %d, want %d", len(def.Fields), len(want)) + } + for i, name := range want { + if def.Fields[i].Name != name { + t.Errorf("Fields[%d].Name = %q, want %q", i, def.Fields[i].Name, name) + } + } +} + +func TestParseReadsFieldAttributes(t *testing.T) { + def := parsed(t) + + field, ok := def.Field("NoticeBody") + if !ok { + t.Fatal("Field(\"NoticeBody\") = _, false, want true") + } + if field.Type != TypeWiki { + t.Errorf("Field(\"NoticeBody\").Type = %q, want %q", field.Type, TypeWiki) + } + if field.Label != "body" { + t.Errorf("Field(\"NoticeBody\").Label = %q, want %q", field.Label, "body") + } + if field.Hint != "wiki syntax allowed" { + t.Errorf("Field(\"NoticeBody\").Hint = %q, want %q", field.Hint, "wiki syntax allowed") + } +} + +func TestParseDefaultsTypeToText(t *testing.T) { + def := parsed(t) + + field, _ := def.Field("Note") + if field.Type != TypeText { + t.Errorf("Field(\"Note\").Type = %q, want %q", field.Type, TypeText) + } +} + +func TestParseReadsSelectOptionsInOrder(t *testing.T) { + def := parsed(t) + + field, _ := def.Field("NoticeType") + want := []Option{{Key: "normal", Label: "plain"}, {Key: "important", Label: "*"}} + if len(field.Options) != len(want) { + t.Fatalf("len(Options) = %d, want %d", len(field.Options), len(want)) + } + for i := range want { + if field.Options[i] != want[i] { + t.Errorf("Options[%d] = %+v, want %+v", i, field.Options[i], want[i]) + } + } + if field.Default != "normal" { + t.Errorf("Field(\"NoticeType\").Default = %q, want %q", field.Default, "normal") + } +} + +func TestParseKeepsReservedOptionKeysAsText(t *testing.T) { + def, _, err := Parse("[[form]]\nfields:\n done:\n type: select\n values:\n \"08\": eight\n yes: on\n[[/form]]") + if err != nil { + t.Fatalf("Parse() err = %v, want nil", err) + } + field, _ := def.Field("done") + want := []string{"08", "yes"} + for i, key := range want { + if field.Options[i].Key != key { + t.Errorf("Options[%d].Key = %q, want %q", i, field.Options[i].Key, key) + } + } +} + +func TestParseFindsNoBlock(t *testing.T) { + def, found, err := Parse("plain page") + if err != nil { + t.Fatalf("Parse() err = %v, want nil", err) + } + if found { + t.Error("Parse(\"plain page\") found = true, want false") + } + if def != nil { + t.Errorf("Parse(\"plain page\") = %+v, want nil", def) + } +} + +func TestStripRemovesTheBlock(t *testing.T) { + got := Strip("head\n[[form]]\nfields:\n a: {}\n[[/form]]\ntail") + if want := "head\n\ntail"; got != want { + t.Errorf("Strip() = %q, want %q", got, want) + } +} + +func TestStripLeavesAPageWithoutOne(t *testing.T) { + if got := Strip("head\ntail"); got != "head\ntail" { + t.Errorf("Strip() = %q, want %q", got, "head\ntail") + } +} + +func TestParseData(t *testing.T) { + values, err := ParseData("NoticeBody: \"one\\ntwo\"\nNoticeType: important\nPinnedNotice: '0'\n") + if err != nil { + t.Fatalf("ParseData() err = %v, want nil", err) + } + want := map[string]string{"NoticeBody": "one\ntwo", "NoticeType": "important", "PinnedNotice": "0"} + for key, value := range want { + if values[key] != value { + t.Errorf("ParseData()[%q] = %q, want %q", key, values[key], value) + } + } +} + +func TestRawIsTheStoredValue(t *testing.T) { + def := parsed(t) + values := map[string]string{"NoticeType": "important"} + + got, ok := def.Raw(values, "NoticeType") + if !ok { + t.Fatal("Raw(\"NoticeType\") = _, false, want true") + } + if got != "important" { + t.Errorf("Raw(\"NoticeType\") = %q, want %q", got, "important") + } +} + +func TestRawFallsBackToTheDefault(t *testing.T) { + def := parsed(t) + + got, _ := def.Raw(map[string]string{}, "NoticeType") + if got != "normal" { + t.Errorf("Raw(\"NoticeType\") = %q, want %q", got, "normal") + } +} + +func TestRawRejectsAnUnknownField(t *testing.T) { + def := parsed(t) + + if _, ok := def.Raw(map[string]string{"Other": "x"}, "Other"); ok { + t.Error("Raw(\"Other\") = _, true, want false") + } +} + +func TestDataMapsASelectToItsLabel(t *testing.T) { + def := parsed(t) + + got, _ := def.Data(map[string]string{"NoticeType": "important"}, "NoticeType") + if got != "*" { + t.Errorf("Data(\"NoticeType\") = %q, want %q", got, "*") + } +} + +func TestDataLeavesWikiUnescaped(t *testing.T) { + def := parsed(t) + + got, _ := def.Data(map[string]string{"NoticeBody": "**bold**"}, "NoticeBody") + if got != "**bold**" { + t.Errorf("Data(\"NoticeBody\") = %q, want %q", got, "**bold**") + } +} + +func TestDataEscapesText(t *testing.T) { + def := parsed(t) + + got, _ := def.Data(map[string]string{"Note": "**bold**"}, "Note") + if want := "@@**bold**@@"; got != want { + t.Errorf("Data(\"Note\") = %q, want %q", got, want) + } +} + +func TestFieldMatchesWithoutCase(t *testing.T) { + def := parsed(t) + + if _, ok := def.Field("pinnednotice"); !ok { + t.Error("Field(\"pinnednotice\") = _, false, want true") + } +} + +func TestLabelAndHint(t *testing.T) { + def := parsed(t) + + if got, _ := def.Label("NoticeType"); got != "kind" { + t.Errorf("Label(\"NoticeType\") = %q, want %q", got, "kind") + } + if got, _ := def.Hint("NoticeBody"); got != "wiki syntax allowed" { + t.Errorf("Hint(\"NoticeBody\") = %q, want %q", got, "wiki syntax allowed") + } +} + +func TestEscapeMarkup(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"plain", "@@plain@@"}, + {"a@@b", "@@a@@@@@@@@@@b@@"}, + } + for _, c := range cases { + if got := EscapeMarkup(c.in); got != c.want { + t.Errorf("EscapeMarkup(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestParseVar(t *testing.T) { + cases := []struct { + in string + kind string + field string + ok bool + }{ + {"form_data{Body}", VarData, "Body", true}, + {"form_raw{Body}", VarRaw, "Body", true}, + {"form_label{Body}", VarLabel, "Body", true}, + {"form_hint{Body}", VarHint, "Body", true}, + {"form_data{}", "", "", false}, + {"form_data{Body", "", "", false}, + {"content{1}", "", "", false}, + {"form_other{Body}", "", "", false}, + } + for _, c := range cases { + kind, field, ok := ParseVar(c.in) + if ok != c.ok { + t.Errorf("ParseVar(%q) ok = %v, want %v", c.in, ok, c.ok) + continue + } + if kind != c.kind || field != c.field { + t.Errorf("ParseVar(%q) = %q, %q, want %q, %q", c.in, kind, field, c.kind, c.field) + } + } +} diff --git a/internal/form/vars.go b/internal/form/vars.go new file mode 100644 index 00000000..9cc3bddd --- /dev/null +++ b/internal/form/vars.go @@ -0,0 +1,27 @@ +package form + +import "strings" + +const ( + VarData = "form_data" + VarRaw = "form_raw" + VarLabel = "form_label" + VarHint = "form_hint" +) + +var varNames = []string{VarData, VarRaw, VarLabel, VarHint} + +func ParseVar(name string) (kind, field string, ok bool) { + for _, prefix := range varNames { + rest, cut := strings.CutPrefix(name, prefix+"{") + if !cut { + continue + } + field, cut = strings.CutSuffix(rest, "}") + if !cut || field == "" { + return "", "", false + } + return prefix, field, true + } + return "", "", false +} diff --git a/internal/htmlsource/htmlsource.go b/internal/htmlsource/htmlsource.go new file mode 100644 index 00000000..db2f0c73 --- /dev/null +++ b/internal/htmlsource/htmlsource.go @@ -0,0 +1,262 @@ +// Package htmlsource turns the forum HTML a backup carries back into wikitext. +package htmlsource + +import ( + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// Convert reads a fragment, not a document, because a forum post is a piece of +// a page and never carries its own html element. +func Convert(source string) string { + body := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body} + nodes, err := html.ParseFragment(strings.NewReader(source), body) + if err != nil { + return "" + } + c := &converter{} + for _, n := range nodes { + c.footnotesFrom(n) + } + var b strings.Builder + for _, n := range nodes { + b.WriteString(c.node(n)) + } + return b.String() +} + +type converter struct { + footnotes map[string]string +} + +func (c *converter) children(n *html.Node) string { + var b strings.Builder + for child := n.FirstChild; child != nil; child = child.NextSibling { + b.WriteString(c.node(child)) + } + return b.String() +} + +func (c *converter) node(n *html.Node) string { + switch n.Type { + case html.TextNode: + // A newline inside the HTML is layout, and wikitext reads it as a break. + return strings.ReplaceAll(n.Data, "\n", "") + case html.ElementNode: + default: + return "" + } + + switch n.Data { + case "p": + return c.children(n) + "\n\n" + case "em": + return "//" + c.children(n) + "//" + case "strong", "b": + return "**" + c.children(n) + "**" + case "u": + return "__" + c.children(n) + "__" + case "strike", "s": + return "--" + c.children(n) + "--" + case "sup": + return c.superscript(n) + case "sub": + return ",," + c.children(n) + ",," + case "br": + return "\n" + case "iframe": + return "[[iframe " + attr(n, "src") + attrs(n, "src") + "]]" + case "span": + return c.span(n) + case "blockquote": + lines := strings.Split(strings.TrimSpace(c.children(n)), "\n") + return "> " + strings.Join(lines, "\n> ") + "\n" + case "div": + return c.div(n) + case "a": + return "[[a" + attrs(n) + "]]" + c.children(n) + "[[/a]]" + case "img": + return "[[image " + attr(n, "src") + attrs(n, "src", "alt") + "]]" + case "hr": + return "----\n" + case "ul", "li", "ol": + return "[[" + n.Data + "]]\n" + c.children(n) + "[[/" + n.Data + "]]\n" + case "h1", "h2", "h3", "h4", "h5", "h6", "h7": + return strings.Repeat("+", int(n.Data[1]-'0')) + " " + oneLine(text(n)) + "\n" + case "tt": + return "{{" + c.children(n) + "}}" + case "table": + return "[[table" + attrs(n) + "]]\n" + c.children(n) + "[[/table]]\n" + case "tbody": + return c.children(n) + case "tr": + return "[[row" + attrs(n) + "]]\n" + c.children(n) + "[[/row]]\n" + case "td": + return "[[cell" + attrs(n) + "]]\n" + c.children(n) + "[[/cell]]\n" + case "th": + return "[[hcell" + attrs(n) + "]]\n" + c.children(n) + "[[/hcell]]\n" + case "script": + return "" + case "dl": + return definitions(n) + } + return c.children(n) +} + +func (c *converter) superscript(n *html.Node) string { + if !hasClass(n, "footnoteref") { + return "^^" + c.children(n) + "^^" + } + return "[[footnote]]" + c.footnotes[strings.TrimSpace(text(n))] + "[[/footnote]]" +} + +func (c *converter) span(n *html.Node) string { + switch { + case hasClass(n, "printuser"): + star := "" + if hasClass(n, "avatarhover") { + star = "*" + } + return "[[" + star + "user " + userName(n) + "]]" + case hasClass(n, "math-inline"): + return "[[$ " + strings.TrimSpace(strings.Trim(text(n), "$")) + " $]]" + case hasClass(n, "equation-number"): + return "" + } + return "[[span" + attrs(n) + "]]" + c.children(n) + "[[/span]]" +} + +var plainDivClasses = []string{ + "rimg", "limg", "cimg", "blockquote", "сimg", "scpnet-progress-bar", + "scpnet-progress-bar__tick", "block-error", "collapsible-block-unfolded-link", +} + +func (c *converter) div(n *html.Node) string { + switch { + case !hasAnyClass(n) || hasClass(n, plainDivClasses...): + return c.plainDiv(n) + case hasClass(n, "collapsible-block"): + return c.collapsible(n) + case hasClass(n, "yui-navset"): + return c.tabview(n) + case hasClass(n, "code"): + return c.code(n) + case hasClass(n, "footnotes-footer"): + return "[[footnoteblock title=\"" + escapeValue(oneLine(text(find(n, "div", "title")))) + "\"]]\n" + case hasClass(n, "bibitems"): + return c.bibliography(n) + case hasClass(n, "image-container"): + return c.image(n) + case hasClass(n, "content-separator"): + return "====\n" + case hasClass(n, "math-equation"): + return "[[math]]\n" + strings.TrimSpace(text(n)) + "\n[[/math]]\n" + case hasClass(n, "wiki-note"): + return "[[note]]\n" + c.children(n) + "[[/note]]\n" + } + return c.plainDiv(n) +} + +func (c *converter) plainDiv(n *html.Node) string { + return "[[div" + attrs(n) + "]]\n" + c.children(n) + "[[/div]]\n" +} + +func (c *converter) collapsible(n *html.Node) string { + show := oneLine(text(find(find(n, "div", "collapsible-block-folded"), "a", "collapsible-block-link"))) + hide := oneLine(text(find(find(find(n, "div", "collapsible-block-unfolded"), + "div", "collapsible-block-unfolded-link"), "a", "collapsible-block-link"))) + return "[[collapsible show=\"" + escapeValue(show) + "\" hide=\"" + escapeValue(hide) + "\"]]\n" + + c.children(find(n, "div", "collapsible-block-content")) + "[[/collapsible]]\n" +} + +func (c *converter) tabview(n *html.Node) string { + var titles []string + for _, li := range findAll(find(n, "ul", "yui-nav"), "li", "") { + titles = append(titles, oneLine(text(li))) + } + var b strings.Builder + b.WriteString("[[tabview]]\n") + for i, tab := range findAll(find(n, "div", "yui-content"), "div", "") { + title := "" + if i < len(titles) { + title = titles[i] + } + b.WriteString("[[tab title=\"" + escapeValue(title) + "\"]]\n") + b.WriteString(c.children(tab)) + b.WriteString("[[/tab]]\n") + } + b.WriteString("[[/tabview]]\n") + return b.String() +} + +func (c *converter) code(n *html.Node) string { + if pre := find(n, "pre", ""); pre != nil { + if code := find(pre, "code", ""); code != nil { + return "[[code]]\n" + text(code) + "\n[[/code]]\n" + } + } + if main := find(n, "div", "hl-main"); main != nil { + if pre := find(main, "pre", ""); pre != nil { + return "[[code]]\n" + text(pre) + "\n[[/code]]\n" + } + } + return c.plainDiv(n) +} + +func (c *converter) bibliography(n *html.Node) string { + var b strings.Builder + b.WriteString("[[bibliography title=\"" + escapeValue(oneLine(text(find(n, "div", "title")))) + "\"]]\n") + for i, item := range findAll(n, "div", "bibitem") { + b.WriteString(": cite" + itoa(i+1) + " : " + strings.TrimSpace(cutLabel(c.children(item))) + "\n") + } + b.WriteString("[[/bibliography]]\n") + return b.String() +} + +var imagePrefixes = [][2]string{ + {"floatleft", "f<"}, {"floatright", "f>"}, + {"alignleft", "<"}, {"alignright", ">"}, {"aligncenter", "="}, +} + +func (c *converter) image(n *html.Node) string { + prefix := "" + for _, pair := range imagePrefixes { + if hasClass(n, pair[0]) { + prefix = pair[1] + } + } + img := find(n, "img", "") + if img == nil { + return c.plainDiv(n) + } + return "[[" + prefix + "image " + attr(img, "src") + attrs(img, "src", "alt") + "]]" +} + +func definitions(n *html.Node) string { + var b strings.Builder + for child := n.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode || child.Data != "dt" { + continue + } + value := "" + for after := child.NextSibling; after != nil; after = after.NextSibling { + if after.Type == html.ElementNode && after.Data == "dd" { + value = text(after) + break + } + } + b.WriteString(": " + spaced(text(child)) + " : " + spaced(value) + "\n") + } + return b.String() +} + +// The number a bibliography item opens with belongs to the list rather than to +// the text, so the first two characters go. +func cutLabel(s string) string { + if len(s) > 2 { + return s[2:] + } + return s +} diff --git a/internal/htmlsource/htmlsource_test.go b/internal/htmlsource/htmlsource_test.go new file mode 100644 index 00000000..080f7f6e --- /dev/null +++ b/internal/htmlsource/htmlsource_test.go @@ -0,0 +1,69 @@ +package htmlsource + +import ( + "os" + "strings" + "testing" +) + +func split(body string) []struct{ Name, Text string } { + var out []struct{ Name, Text string } + name := "" + var lines []string + flush := func() { + if name == "" { + return + } + out = append(out, struct{ Name, Text string }{name, strings.Join(lines, "\n")}) + } + for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { + if strings.HasPrefix(line, "=== ") && strings.HasSuffix(line, " ===") { + flush() + name = strings.TrimSuffix(strings.TrimPrefix(line, "=== "), " ===") + lines = nil + continue + } + if name != "" { + lines = append(lines, line) + } + } + flush() + return out +} + +func TestConvertMatchesGolden(t *testing.T) { + cases, err := os.ReadFile("testdata/cases.html") + if err != nil { + t.Fatalf("ReadFile(cases) err = %v, want nil", err) + } + golden, err := os.ReadFile("testdata/html.golden") + if err != nil { + t.Fatalf("ReadFile(golden) err = %v, want nil", err) + } + + want := map[string]string{} + for _, one := range split(string(golden)) { + want[one.Name] = one.Text + } + for _, one := range split(string(cases)) { + expected, ok := want[one.Name] + if !ok { + t.Errorf("golden has no case %q, want one", one.Name) + continue + } + got := Convert(strings.Trim(one.Text, "\n")) + if strings.TrimRight(got, "\n") != strings.TrimRight(expected, "\n") { + t.Errorf("Convert(%s) = %q, want %q", one.Name, got, expected) + } + } +} + +func TestGoldenCoversEveryCase(t *testing.T) { + cases, err := os.ReadFile("testdata/cases.html") + if err != nil { + t.Fatalf("ReadFile(cases) err = %v, want nil", err) + } + if got := len(split(string(cases))); got < 30 { + t.Errorf("len(cases) = %d, want at least 30", got) + } +} diff --git a/internal/htmlsource/nodes.go b/internal/htmlsource/nodes.go new file mode 100644 index 00000000..b7f48828 --- /dev/null +++ b/internal/htmlsource/nodes.go @@ -0,0 +1,160 @@ +package htmlsource + +import ( + "strconv" + "strings" + + "golang.org/x/net/html" +) + +func attr(n *html.Node, name string) string { + if n == nil { + return "" + } + for _, a := range n.Attr { + if a.Key == name { + return a.Val + } + } + return "" +} + +// attrs writes every attribute back in the order it was parsed, which is the +// order it was written, so a round trip does not reshuffle a tag. +func attrs(n *html.Node, skip ...string) string { + var b strings.Builder + for _, a := range n.Attr { + if contains(skip, a.Key) { + continue + } + b.WriteString(" " + a.Key + `="` + escapeValue(a.Val) + `"`) + } + return b.String() +} + +func escapeValue(v string) string { + v = strings.ReplaceAll(v, `\`, `\\`) + return strings.ReplaceAll(v, `"`, `\"`) +} + +func classes(n *html.Node) []string { + if n == nil { + return nil + } + return strings.Fields(attr(n, "class")) +} + +func hasAnyClass(n *html.Node) bool { return len(classes(n)) > 0 } + +func hasClass(n *html.Node, want ...string) bool { + held := classes(n) + for _, one := range want { + if contains(held, one) { + return true + } + } + return false +} + +func contains(list []string, want string) bool { + for _, one := range list { + if one == want { + return true + } + } + return false +} + +func find(n *html.Node, tag, class string) *html.Node { + if n == nil { + return nil + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + if matches(child, tag, class) { + return child + } + if found := find(child, tag, class); found != nil { + return found + } + } + return nil +} + +func findAll(n *html.Node, tag, class string) []*html.Node { + if n == nil { + return nil + } + var out []*html.Node + for child := n.FirstChild; child != nil; child = child.NextSibling { + if matches(child, tag, class) { + out = append(out, child) + continue + } + out = append(out, findAll(child, tag, class)...) + } + return out +} + +func matches(n *html.Node, tag, class string) bool { + if n.Type != html.ElementNode || n.Data != tag { + return false + } + return class == "" || hasClass(n, class) +} + +func text(n *html.Node) string { + if n == nil { + return "" + } + var b strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.TextNode { + b.WriteString(node.Data) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(n) + return b.String() +} + +func oneLine(s string) string { + return strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) +} + +func spaced(s string) string { return strings.ReplaceAll(s, "\n", " ") } + +func itoa(n int) string { return strconv.Itoa(n) } + +func userName(n *html.Node) string { + href := attr(find(n, "a", ""), "href") + if cut := strings.LastIndex(href, "/"); cut >= 0 { + return href[cut+1:] + } + return href +} + +// Every reference in the document reaches for the same block at the end of it, +// so the bodies are collected once. +func (c *converter) footnotesFrom(root *html.Node) { + block := root + if !matches(root, "div", "footnotes-footer") { + block = find(root, "div", "footnotes-footer") + } + if block == nil { + return + } + if c.footnotes == nil { + c.footnotes = map[string]string{} + } + for _, note := range findAll(block, "div", "footnote-footer") { + link := find(note, "a", "") + number := strings.TrimSpace(text(link)) + if link != nil && link.Parent != nil { + link.Parent.RemoveChild(link) + } + c.footnotes[number] = strings.TrimSpace(cutLabel(c.children(note))) + } +} diff --git a/internal/htmlsource/testdata/cases.html b/internal/htmlsource/testdata/cases.html new file mode 100644 index 00000000..733909bc --- /dev/null +++ b/internal/htmlsource/testdata/cases.html @@ -0,0 +1,75 @@ +=== text === +plain text with a +newline in it +=== paragraph === +

      one

      two

      +=== emphasis === +

      a b c d e f

      +=== superscript === +

      up down

      +=== line break === +

      a
      b

      +=== iframe === + +=== printuser === +probe-author +=== printuser with avatar === +probe-author +=== inline math === +$x^2$ +=== equation number === +(1) +=== plain span === +red +=== blockquote === +

      one

      two

      +=== div with an allowed class === +
      inner
      +=== collapsible === + +=== tabview === +
      • first
      • second

      one

      two

      +=== code block === +
      print(1)
      +=== highlighted code block === +
      print(2)
      +=== footnote === +

      text1

      +=== bibliography === +
      Bibliography
      1. first item
      2. second item
      +=== image container === +
      a
      +=== image container aligned === +
      +=== content separator === +
      +=== math equation === +
      x = 1
      +=== wiki note === +

      noted

      +=== unknown div === +
      fallback
      +=== anchor === +SCP-173 +=== image === +c +=== horizontal rule === +
      +=== lists === +
      • one
      • two
      1. three
      +=== headings === +

      one

      three

      +=== teletype === +code +=== table === +
      head
      cell
      +=== script === + +=== definition list === +
      term
      meaning
      other
      second
      +=== quotes in attributes === +x +=== backslash in attributes === +
      x
      +=== nested markup === +

      bold and italic

      diff --git a/internal/htmlsource/testdata/html.golden b/internal/htmlsource/testdata/html.golden new file mode 100644 index 00000000..d0fcedaf --- /dev/null +++ b/internal/htmlsource/testdata/html.golden @@ -0,0 +1,156 @@ +=== text === +plain text with anewline in it +=== paragraph === +one + +two + + +=== emphasis === +//a// **b** **c** __d__ --e-- --f-- + + +=== superscript === +^^up^^ ,,down,, + + +=== line break === +a +b + + +=== iframe === +[[iframe https://example.test/x width="300" class="frame"]] +=== printuser === +[[user probe-author]] +=== printuser with avatar === +[[*user probe-author]] +=== inline math === +[[$ x^2 $]] +=== equation number === + +=== plain span === +[[span style="color: red" class="fancy"]]red[[/span]] +=== blockquote === +> one +> +> two + +=== div with an allowed class === +[[div class="rimg"]] +inner[[/div]] + +=== collapsible === +[[collapsible show="show me" hide="hide me"]] +inside + +[[/collapsible]] + +=== tabview === +[[tabview]] +[[tab title="first"]] +one + +[[/tab]] +[[tab title="second"]] +two + +[[/tab]] +[[/tabview]] + +=== code block === +[[code]] +print(1) +[[/code]] + +=== highlighted code block === +[[code]] +print(2) +[[/code]] + +=== footnote === +text[[footnote]]the note[[/footnote]] + +[[footnoteblock title="Footnotes"]] + +=== bibliography === +[[bibliography title="Bibliography"]] +: cite1 : first item +: cite2 : second item +[[/bibliography]] + +=== image container === +[[f>image /local--files/a.png width="50"]] +=== image container aligned === +[[=image /local--files/b.png]] +=== content separator === +==== + +=== math equation === +[[math]] +x = 1 +[[/math]] + +=== wiki note === +[[note]] +noted + +[[/note]] + +=== unknown div === +[[div class="whatever" id="x"]] +fallback[[/div]] + +=== anchor === +[[a href="/scp-173" class="link"]]SCP-173[[/a]] +=== image === +[[image /local--files/c.png width="20"]] +=== horizontal rule === +---- + +=== lists === +[[ul]] +[[li]] +one[[/li]] +[[li]] +two[[/li]] +[[/ul]] +[[ol]] +[[li]] +three[[/li]] +[[/ol]] + +=== headings === ++ one ++++ three + +=== teletype === +{{code}} +=== table === +[[table class="wiki-content-table"]] +[[row]] +[[hcell]] +head[[/hcell]] +[[/row]] +[[row]] +[[cell]] +cell[[/cell]] +[[/row]] +[[/table]] + +=== script === + +=== definition list === +: term : meaning +: other : second + +=== quotes in attributes === +[[span title="a \"quoted\" word"]]x[[/span]] +=== backslash in attributes === +[[div class="rimg" data-path="a\\b"]] +x[[/div]] + +=== nested markup === +**bold //and italic//** + + diff --git a/internal/i18n/hardcoded_test.go b/internal/i18n/hardcoded_test.go new file mode 100644 index 00000000..a1f9373c --- /dev/null +++ b/internal/i18n/hardcoded_test.go @@ -0,0 +1,67 @@ +package i18n + +import ( + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "unicode" +) + +var skipDirs = map[string]bool{ + ".git": true, + ".claude": true, + "node_modules": true, + "target": true, + "postgresql": true, + "venv": true, + "__pycache__": true, +} + +func hasHan(line string) bool { + for _, r := range line { + if unicode.Is(unicode.Han, r) { + return true + } + } + return false +} + +func TestNoHanTextInGoSource(t *testing.T) { + root := filepath.Join("..", "..") + var found []string + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if skipDirs[d.Name()] { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(d.Name(), ".go") || strings.HasSuffix(d.Name(), "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + for i, line := range strings.Split(string(data), "\n") { + if hasHan(line) { + found = append(found, filepath.ToSlash(path)+":"+strconv.Itoa(i+1)+" "+strings.TrimSpace(line)) + } + } + return nil + }) + if err != nil { + t.Fatalf("WalkDir(%s) err = %v, want nil", root, err) + } + + for _, line := range found { + t.Errorf("Han text in shipping Go source: %s", line) + } +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 00000000..a2ba20ca --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,141 @@ +package i18n + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "slices" + "strings" + + "golang.org/x/text/language" +) + +//go:embed locales/*.json +var embedded embed.FS + +const ( + DefaultLanguage = "zh-hans" + embeddedDir = "locales" + fileSuffix = ".json" +) + +type Bundle struct { + catalogs map[string]map[string]string + tagged []string + matcher language.Matcher +} + +func Load(overrideDir string) (*Bundle, error) { + b := &Bundle{catalogs: make(map[string]map[string]string)} + if err := b.merge(embedded, embeddedDir); err != nil { + return nil, err + } + if overrideDir != "" { + if err := b.merge(os.DirFS(overrideDir), "."); err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + } + if len(b.catalogs[DefaultLanguage]) == 0 { + return nil, fmt.Errorf("catalog for default language %q is empty", DefaultLanguage) + } + if err := b.buildMatcher(); err != nil { + return nil, err + } + return b, nil +} + +func (b *Bundle) merge(fsys fs.FS, dir string) error { + names, err := fs.Glob(fsys, path.Join(dir, "*"+fileSuffix)) + if err != nil { + return err + } + for _, name := range names { + lang := Normalize(strings.TrimSuffix(path.Base(name), fileSuffix)) + if lang == "" { + return fmt.Errorf("catalog %s: filename is not a language tag", name) + } + raw, err := fs.ReadFile(fsys, name) + if err != nil { + return err + } + var entries map[string]string + if err := json.Unmarshal(raw, &entries); err != nil { + return fmt.Errorf("parse catalog %s: %w", name, err) + } + catalog, ok := b.catalogs[lang] + if !ok { + catalog = make(map[string]string, len(entries)) + b.catalogs[lang] = catalog + } + for id, text := range entries { + catalog[id] = text + } + } + return nil +} + +func (b *Bundle) Languages() []string { + langs := make([]string, 0, len(b.catalogs)) + for lang := range b.catalogs { + langs = append(langs, lang) + } + slices.Sort(langs) + return langs +} + +func (b *Bundle) Has(lang string) bool { + _, ok := b.catalogs[Normalize(lang)] + return ok +} + +func (b *Bundle) Localizer(lang string) *Localizer { + lang = Normalize(lang) + if !b.Has(lang) { + lang = DefaultLanguage + } + return &Localizer{bundle: b, lang: lang} +} + +type Localizer struct { + bundle *Bundle + lang string +} + +func (l *Localizer) Lang() string { return l.lang } + +func (l *Localizer) T(id string, args ...any) string { + text, ok := l.bundle.catalogs[l.lang][id] + if !ok { + text, ok = l.bundle.catalogs[DefaultLanguage][id] + } + if !ok { + return id + } + return expand(text, args) +} + +func Normalize(lang string) string { + return strings.ToLower(strings.TrimSpace(lang)) +} + +func expand(text string, args []any) string { + if len(args) < 2 || !strings.Contains(text, "{") { + return text + } + pairs := make([]string, 0, len(args)) + for i := 0; i+1 < len(args); i += 2 { + name, ok := args[i].(string) + if !ok { + continue + } + pairs = append(pairs, "{"+name+"}", fmt.Sprint(args[i+1])) + } + if len(pairs) == 0 { + return text + } + return strings.NewReplacer(pairs...).Replace(text) +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 00000000..a4a2d937 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,144 @@ +package i18n + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" +) + +func writeCatalog(t *testing.T, dir, lang string, entries map[string]string) { + t.Helper() + raw, err := json.Marshal(entries) + if err != nil { + t.Fatalf("Marshal() err = %v, want nil", err) + } + if err := os.WriteFile(filepath.Join(dir, lang+fileSuffix), raw, 0o644); err != nil { + t.Fatalf("WriteFile() err = %v, want nil", err) + } +} + +func load(t *testing.T, overrideDir string) *Bundle { + t.Helper() + b, err := Load(overrideDir) + if err != nil { + t.Fatalf("Load(%q) err = %v, want nil", overrideDir, err) + } + return b +} + +func TestLoadReadsEmbeddedCatalog(t *testing.T) { + l := load(t, "").Localizer(DefaultLanguage) + if got := l.T("button-copy-clipboard"); got != "复制" { + t.Errorf("T(%q) = %q, want %q", "button-copy-clipboard", got, "复制") + } +} + +func TestTReturnsIDWhenMissing(t *testing.T) { + l := load(t, "").Localizer(DefaultLanguage) + if got := l.T("no-such-id"); got != "no-such-id" { + t.Errorf("T(%q) = %q, want %q", "no-such-id", got, "no-such-id") + } +} + +func TestTFallsBackToDefaultLanguage(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, "en", map[string]string{"toc-open": "Expand"}) + b := load(t, dir) + + l := b.Localizer("en") + if got := l.T("toc-open"); got != "Expand" { + t.Errorf("T(%q) = %q, want %q", "toc-open", got, "Expand") + } + if got := l.T("toc-close"); got != "关闭" { + t.Errorf("T(%q) = %q, want fallback %q", "toc-close", got, "关闭") + } +} + +func TestTSubstitutesNamedArgs(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, DefaultLanguage, map[string]string{"greet": "{who} 有 {count} 条消息"}) + l := load(t, dir).Localizer(DefaultLanguage) + + if got := l.T("greet", "who", "Kakushi", "count", 3); got != "Kakushi 有 3 条消息" { + t.Errorf("T(greet) = %q, want %q", got, "Kakushi 有 3 条消息") + } +} + +func TestTLeavesUnsuppliedPlaceholders(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, DefaultLanguage, map[string]string{"greet": "{who} 有 {count} 条消息"}) + l := load(t, dir).Localizer(DefaultLanguage) + + if got := l.T("greet", "who", "Kakushi"); got != "Kakushi 有 {count} 条消息" { + t.Errorf("T(greet) = %q, want %q", got, "Kakushi 有 {count} 条消息") + } +} + +func TestTIgnoresTrailingOddArg(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, DefaultLanguage, map[string]string{"greet": "{who} 来了"}) + l := load(t, dir).Localizer(DefaultLanguage) + + if got := l.T("greet", "who", "Kakushi", "count"); got != "Kakushi 来了" { + t.Errorf("T(greet) = %q, want %q", got, "Kakushi 来了") + } +} + +func TestLoadMergesOverrideDir(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, DefaultLanguage, map[string]string{"toc-open": "打开"}) + l := load(t, dir).Localizer(DefaultLanguage) + + if got := l.T("toc-open"); got != "打开" { + t.Errorf("T(%q) = %q, want override %q", "toc-open", got, "打开") + } + if got := l.T("toc-close"); got != "关闭" { + t.Errorf("T(%q) = %q, want builtin %q", "toc-close", got, "关闭") + } +} + +func TestLoadIgnoresMissingOverrideDir(t *testing.T) { + l := load(t, filepath.Join(t.TempDir(), "missing")).Localizer(DefaultLanguage) + if got := l.T("toc-close"); got != "关闭" { + t.Errorf("T(%q) = %q, want %q", "toc-close", got, "关闭") + } +} + +func TestLoadRejectsBadJSON(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "en"+fileSuffix), []byte("{"), 0o644); err != nil { + t.Fatalf("WriteFile() err = %v, want nil", err) + } + if _, err := Load(dir); err == nil { + t.Error("Load() err = nil, want non-nil") + } +} + +func TestLocalizerFallsBackForUnknownLanguage(t *testing.T) { + l := load(t, "").Localizer("de") + if l.Lang() != DefaultLanguage { + t.Errorf("Lang() = %q, want %q", l.Lang(), DefaultLanguage) + } +} + +func TestLocalizerNormalizesLanguageTag(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, "en", map[string]string{"toc-open": "Expand"}) + b := load(t, dir) + + if got := b.Localizer(" EN ").Lang(); got != "en" { + t.Errorf("Localizer(%q).Lang() = %q, want %q", " EN ", got, "en") + } +} + +func TestBundleLanguages(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, "en", map[string]string{"toc-open": "Expand"}) + got := load(t, dir).Languages() + + if !slices.Equal(got, []string{"en", "zh-hans"}) { + t.Errorf("Languages() = %v, want %v", got, []string{"en", "zh-hans"}) + } +} diff --git a/internal/i18n/locales/zh-hans.json b/internal/i18n/locales/zh-hans.json new file mode 100644 index 00000000..b0c42082 --- /dev/null +++ b/internal/i18n/locales/zh-hans.json @@ -0,0 +1,844 @@ +{ + "accept.error-invalid": "无效邀请。", + "accept.error-name-taken": "所选用户名已被使用。", + "accept.error-password-mismatch": "两次输入的密码不一致。", + "accept.error-password-required": "必须填写密码。", + "accept.password": "密码", + "accept.password2": "确认密码", + "accept.submit": "继续", + "accept.title": "注册", + "accept.username": "用户名", + "admin.account-needs-superuser": "修改账号资料需要超级管理员。", + "admin.account-state-hint": "以下设置对实例中的所有站点生效,只有超级管理员可以进行更改。", + "admin.activate": "激活账号", + "admin.active": "启用", + "admin.activity-edits": "最近编辑", + "admin.activity-of": "{name} 的动态", + "admin.activity-posts": "最近发帖", + "admin.activity-votes": "最近投票", + "admin.add": "新增", + "admin.addresses-cleared": "已清掉 {n} 条 IP 记录。", + "admin.admin-log": "操作记录", + "admin.all": "全部", + "admin.back": "返回", + "admin.back-to-list": "返回列表", + "admin.batch": "对选中的页面", + "admin.batch-delete": "删除", + "admin.batch-revert": "回退", + "admin.cancel": "取消", + "admin.category-no-name": "分类名称不能为空", + "admin.claim-no-user": "请选择一个还没被认领的 Wikidot 账号", + "admin.clear-addresses": "清空 IP 记录", + "admin.clear-icon": "清除当前图标", + "admin.confirm-delete": "确认删除这 {n} 个页面", + "admin.confirm-revert": "确认把这 {n} 个页面各回退 {steps} 个版本", + "admin.count": "共 {n} 条", + "admin.create": "创建", + "admin.dash-actions": "最近的操作", + "admin.dash-changes": "最近的变更", + "admin.dash-queue": "待处理", + "admin.dash-site": "站点", + "admin.dashboard": "仪表板", + "admin.delete": "删除", + "admin.denied": "您似乎不被允许访问管理面板。", + "admin.denied-title": "无法进入管理面板", + "admin.did-change": "修改", + "admin.did-create": "新建", + "admin.did-delete": "删除", + "admin.done": "已完成", + "admin.edit": "编辑", + "admin.email-at_signup": "注册时必填", + "admin.email-optional": "可选", + "admin.email-required": "必须验证", + "admin.empty": "还没有内容", + "admin.f-activated": "使用情况", + "admin.f-active": "账号启用", + "admin.f-active-theme": "站点主题", + "admin.f-admin-notes": "处理备注", + "admin.f-api-key": "API 密钥", + "admin.f-articles": "文章数", + "admin.f-auth-icon": "登录页图标", + "admin.f-author": "提交人", + "admin.f-badge-bg": "徽章背景色", + "admin.f-badge-border": "徽章边框", + "admin.f-badge-text": "徽章文字", + "admin.f-badge-text-color": "徽章文字色", + "admin.f-bio": "简介", + "admin.f-body": "正文", + "admin.f-can-message": "可发私信", + "admin.f-categories": "分类数", + "admin.f-category": "分类", + "admin.f-created": "提交时间", + "admin.f-css": "CSS", + "admin.f-default-role": "注册后的身分组", + "admin.f-description": "说明", + "admin.f-display-name": "显示名", + "admin.f-domain": "文章域名", + "admin.f-email": "邮箱", + "admin.f-email-policy": "邮箱验证要求", + "admin.f-external-url": "外部链接", + "admin.f-favicon": "站点图标", + "admin.f-footer-license": "页脚授权说明", + "admin.f-for-comments": "承载文章评论", + "admin.f-forum-active": "论坛启用", + "admin.f-forum-inactive-until": "论坛停用至", + "admin.f-granted-role": "通过后发放的身分组", + "admin.f-group-votes": "评分合并计票", + "admin.f-headline": "副标题", + "admin.f-hidden": "隐藏", + "admin.f-home-page": "主页名称", + "admin.f-icon": "图标", + "admin.f-icon-color": "图标颜色", + "admin.f-id": "编号", + "admin.f-inactive-until": "停用至", + "admin.f-indexed": "计入索引", + "admin.f-inline-mode": "用户名旁显示", + "admin.f-invite-kind": "类型", + "admin.f-invite-to": "发给谁", + "admin.f-is-staff": "可进管理后台", + "admin.f-language": "站点语言", + "admin.f-media-domain": "文件域名", + "admin.f-membership-password": "密码入组", + "admin.f-membership-role": "入组后的身分组", + "admin.f-messages": "相关消息", + "admin.f-name": "名称", + "admin.f-new-name": "新名称", + "admin.f-order": "排序", + "admin.f-override": "覆盖", + "admin.f-page": "页面", + "admin.f-password": "口令", + "admin.f-password-help": "找回密码求助文案", + "admin.f-permission": "权限", + "admin.f-preview": "预览", + "admin.f-priority": "优先级", + "admin.f-profile-mode": "个人资料显示", + "admin.f-rank": "权限级别", + "admin.f-rating-mode": "评分系统", + "admin.f-reason": "举报理由", + "admin.f-reported": "被举报人", + "admin.f-reporter": "举报人", + "admin.f-result": "结果", + "admin.f-reviewed-by": "处理人", + "admin.f-revisions": "版本", + "admin.f-role": "身分组", + "admin.f-roles": "身分组", + "admin.f-section": "版块", + "admin.f-short-name": "简称", + "admin.f-signup-notice": "注册页提示", + "admin.f-site-slug": "站点缩写", + "admin.f-site-title": "站点标题", + "admin.f-slug": "标识名", + "admin.f-source": "源代码", + "admin.f-source-page": "来源页面", + "admin.f-staff-only": "仅管理员可见", + "admin.f-state": "状态", + "admin.f-status": "状态", + "admin.f-subject": "标题", + "admin.f-superuser": "超级管理员", + "admin.f-system-theme": "系统页主题", + "admin.f-tag": "标签", + "admin.f-tags": "标签数", + "admin.f-theme-mode": "样式来源", + "admin.f-threads": "主题数", + "admin.f-time-zone": "站点时区", + "admin.f-title": "标题", + "admin.f-updated": "更新时间", + "admin.f-user": "用户", + "admin.f-user-tags": "用户可否新建标签", + "admin.f-user-type": "类型", + "admin.f-username": "用户名", + "admin.f-users": "人数", + "admin.f-verified-role": "认领后的身分组", + "admin.f-votes-title": "计票组名称", + "admin.f-wikidot-account": "Wikidot 账号", + "admin.f-wikidot-username": "Wikidot 用户名", + "admin.forum-categories": "论坛分类", + "admin.forum-no-name": "名称不能为空", + "admin.forum-no-section": "必须选一个版块", + "admin.forum-posts": "最近的发帖", + "admin.forum-sections": "论坛版块", + "admin.group-forum": "论坛", + "admin.group-members": "成员", + "admin.group-queue": "处理", + "admin.group-records": "记录", + "admin.group-site": "站点", + "admin.h-activate": "把这个邮箱绑到该账号上,并寄出接受链接。对方走完链接账号才会启用。", + "admin.h-auth-icon": "登录、注册、找回密码这些页面顶上的图标。留空用内置的那个。", + "admin.h-badge-preview": "挂件与个人资料上显示的样子", + "admin.h-badge-text": "留空则显示标识名", + "admin.h-bot": "机器人不用密码登录,凭 API 密钥调用接口。", + "admin.h-browser-zone": "你的浏览器所在时区:", + "admin.h-builtin-slug": "内置身分组的标识名不能改", + "admin.h-claim-link": "只列出还没被认领的 Wikidot 账号。生成链接不改动账号,认领成功时它才转成普通账号,用户名沿用原来的 Wikidot 用户名。", + "admin.h-clear-addresses": "清掉之后要等账号再次登录或操作才会重新积累,已经存在的关联会一起消失。", + "admin.h-default-role": "任何人注册完成后自动获得", + "admin.h-favicon": "浏览器标签页上的那个小图标。PNG / JPEG / GIF / WebP / ICO,2 MB 以内。", + "admin.h-footer-license": "可使用wikitext。[[module time]] 会被渲染,其余模块按纯文本处理", + "admin.h-granted-role": "状态改为通过时立刻发放", + "admin.h-icon": "一个 SVG 文件。只有「用户名旁显示」选了图标时才会用到,颜色由下面那一项注进去。", + "admin.h-icon-color": "会被写进图标 SVG 里", + "admin.h-invite-link": "会先建一个停用的账号占位,对方走完链接才启用。", + "admin.h-language": "访客的浏览器没有要求其他语言时用它。", + "admin.h-link-made": "链接只显示这一次,复制走。它也记在邀请链接那一屏里。", + "admin.h-mail-invite": "会先建一个停用的账号占位,并把接受邀请的链接寄到这个邮箱。", + "admin.h-media-domain": "文件放在独立域名上是一道安全边界,建议和文章域名填成不同的两个。填成一样也能跑,代价是上传的 HTML 与站点同源", + "admin.h-new-password": "至少 8 位。之后可以由本人在设置里改。", + "admin.h-password-help": "找回密码页上「收不到邮件?」点开后显示的内容。留空使用用默认文本。支持 wikitext,但不会渲染任何模块。", + "admin.h-permissions": "继承表示这个身分组不做规定;任何一个身分组拒绝即为拒绝", + "admin.h-rename": "带分类就写成 分类:名称,不带分类就只写名称。", + "admin.h-steps": "回退几个版本", + "admin.h-suspicious": "把共用同一个地址的账号连起来;按住节点可以进行拖拽操作。", + "admin.h-system-theme": "登录、个人资料、管理后台这些页面用的主题", + "admin.h-theme-slug": "决定 /-/theme/<标识名>.css 这条地址", + "admin.h-time-zone": "访客看到的时间一律是他们各自的当地时间,不受此处设置影响。站点时区只用在以下情况:按日期筛选页面时的计算方式以及浏览器脚本运行之前页面上先显示的时间。此处填 IANA 时区名,例如 Asia/Shanghai 或 UTC。", + "admin.h-until": "按你所在地的当地时间填写,留空表示不设期限", + "admin.h-verified-role": "认领 Wikidot 账号成功后自动获得", + "admin.icon-bad-type": "这个文件的类型不对", + "admin.icon-too-large": "图标不能超过 2 MB", + "admin.inactive": "已停用", + "admin.invite-email-taken": "这个邮箱已经属于站点里的某个账号", + "admin.invite-kind-claim": "认领", + "admin.invite-kind-register": "邀请注册", + "admin.invite-no-email": "请填写邮箱", + "admin.invite-not-sent": "邮件没能发出去,检查一下邮件服务配置。", + "admin.invite-open": "未使用", + "admin.invite-sent": "邀请已发往 {email}。", + "admin.invites": "邀请链接", + "admin.l-active": "账号可以登录", + "admin.l-badge-border": "给徽章描一圈边", + "admin.l-can-message": "可以给别人发私信", + "admin.l-for-comments": "这个分类用来放文章的评论", + "admin.l-forum-active": "可以在论坛发言", + "admin.l-group-votes": "在评分明细里单独成组", + "admin.l-indexed": "计入站点索引与搜索;关掉之后这个分类下的页面搜不到", + "admin.l-indexed-tag": "计入搜索;关掉之后带这个标签的页面搜不到", + "admin.l-is-staff": "允许进入管理后台", + "admin.l-membership-password": "允许凭口令加入本站", + "admin.l-override": "为这个身分组单独设定权限", + "admin.l-section-hidden": "对所有人隐藏这个版块", + "admin.l-section-staff-only": "只有管理员看得到", + "admin.l-superuser": "拥有全部权限,不受身分组限制", + "admin.link-made": "链接已生成", + "admin.mail-invite": "邮件邀请", + "admin.make-link": "生成链接", + "admin.membership": "入组申请", + "admin.name-taken": "这个用户名已经被占用", + "admin.new-bot": "新建机器人", + "admin.new-category": "新建分类", + "admin.new-claim-link": "生成认领链接", + "admin.new-invite-link": "生成邀请链接", + "admin.new-role": "新建身分组", + "admin.new-role-category": "新分类名称", + "admin.new-section": "新建版块", + "admin.new-tag": "新建标签", + "admin.new-theme": "新建主题", + "admin.new-user": "新建用户", + "admin.no-mailer": "这台服务器没有配置邮件发送,只能用生成链接的方式邀请。", + "admin.no-unclaimed": "没有还没被认领的 Wikidot 账号", + "admin.off": "已关闭", + "admin.on": "已开启", + "admin.open-invites": "未使用的邀请链接", + "admin.open-page": "打开页面", + "admin.page-bad-action": "不认识的操作", + "admin.page-categories": "页面分类", + "admin.page-failed": "操作失败", + "admin.page-no-name": "新名称不能为空,也不能和原来的一样", + "admin.page-no-older": "这个页面没有那么多历史版本", + "admin.page-no-source": "源代码不能为空", + "admin.page-none-picked": "请先勾选页面", + "admin.page-source": "编辑源代码", + "admin.pages": "页面", + "admin.pages-index": "计入搜索", + "admin.pages-noindex": "排除出搜索", + "admin.password-too-short": "密码至少要 8 位", + "admin.perm-allow": "允许", + "admin.perm-ban_members": "封禁成员", + "admin.perm-comment_articles": "评论文章", + "admin.perm-create_articles": "创建文章", + "admin.perm-create_forum_posts": "创建论坛帖子", + "admin.perm-create_forum_threads": "创建论坛主题", + "admin.perm-delete_articles": "删除文章", + "admin.perm-delete_forum_posts": "删除论坛帖子", + "admin.perm-deny": "拒绝", + "admin.perm-edit_articles": "编辑文章", + "admin.perm-edit_forum_posts": "编辑论坛帖子", + "admin.perm-edit_forum_threads": "编辑论坛主题", + "admin.perm-group-admin": "管理后台", + "admin.perm-group-articles": "文章", + "admin.perm-group-forum": "论坛", + "admin.perm-group-members": "成员处置", + "admin.perm-group-other": "其他", + "admin.perm-group-social": "用户互动", + "admin.perm-group-tickets": "工单管理", + "admin.perm-inherit": "继承", + "admin.perm-invite_members": "邀请成员", + "admin.perm-lock_articles": "锁定文章", + "admin.perm-lock_forum_threads": "锁定论坛主题", + "admin.perm-manage_article_authors": "管理文章作者", + "admin.perm-manage_article_files": "管理文章文件", + "admin.perm-manage_bots": "管理机器人账号", + "admin.perm-manage_categories": "管理分类", + "admin.perm-manage_forum": "管理论坛", + "admin.perm-manage_permissions": "访问权限表", + "admin.perm-manage_roles": "管理角色", + "admin.perm-manage_site": "管理站点", + "admin.perm-manage_tags": "管理标签", + "admin.perm-manage_updates": "管理系统更新", + "admin.perm-manage_users": "管理用户", + "admin.perm-move_articles": "移动文章", + "admin.perm-move_forum_threads": "移动论坛主题", + "admin.perm-mute_members": "禁言成员", + "admin.perm-pin_forum_threads": "置顶论坛主题", + "admin.perm-rate_articles": "给文章投票", + "admin.perm-reset_article_votes": "重置投票", + "admin.perm-reset_member_votes": "重置成员评分", + "admin.perm-restrict_member_editing": "禁止成员编辑", + "admin.perm-restrict_member_rating": "禁止成员评分", + "admin.perm-review_membership_applications": "审核入组申请", + "admin.perm-send_direct_message": "发送私信", + "admin.perm-tag_articles": "编辑文章标签", + "admin.perm-view_actions_log": "查看操作记录", + "admin.perm-view_article_comments": "查看文章评论", + "admin.perm-view_articles": "浏览文章", + "admin.perm-view_forum_categories": "浏览论坛分类", + "admin.perm-view_forum_posts": "浏览论坛帖子", + "admin.perm-view_forum_sections": "浏览论坛版块", + "admin.perm-view_forum_threads": "浏览论坛主题", + "admin.perm-view_hidden_forum_sections": "浏览隐藏的论坛版块", + "admin.perm-view_reported_full_conversation": "查看被检举会话全部记录", + "admin.perm-view_sensitive_info": "查看敏感信息", + "admin.perm-view_user_reports": "查看用户检举", + "admin.perm-view_user_tickets": "查看用户工单", + "admin.perm-view_votes_timestamp": "查看投票时间", + "admin.permissions": "权限", + "admin.posts-with-comments": "含文章评论", + "admin.posts-without-comments": "只看论坛", + "admin.queue-none": "没有你能处理的队列", + "admin.rating-disabled": "关闭", + "admin.rating-stars": "星级", + "admin.rating-updown": "顶踩", + "admin.rename": "重命名", + "admin.report-bad-status": "状态的取值不对", + "admin.report-content": "举报内容", + "admin.report-status-dismissed": "已驳回", + "admin.report-status-pending": "待处理", + "admin.report-status-reviewed": "已处理", + "admin.reports": "用户举报", + "admin.reset-votes": "重置评分", + "admin.reset-votes-confirm": "确认清空 {name} 的全部评分", + "admin.reset-votes-warning": "这个账号投过的每一票都会被删掉,受影响文章的分数会立刻变化。无法撤销。", + "admin.review": "处理", + "admin.revoke": "作废", + "admin.role-bad-mode": "展示方式的取值不对", + "admin.role-bad-slug": "标识符只能包含英文字母、数字、- 和 _", + "admin.role-badge": "徽章与图标", + "admin.role-basics": "基本信息", + "admin.role-builtin": "内置身分组不能删除", + "admin.role-categories": "身分组分类", + "admin.role-category-no-name": "分类名称不能为空", + "admin.role-inline-badge": "徽章", + "admin.role-inline-hidden": "不显示", + "admin.role-inline-icon": "图标", + "admin.role-profile-badge": "徽章", + "admin.role-profile-hidden": "不显示", + "admin.role-profile-status": "状态", + "admin.role-votes": "评分显示", + "admin.roles": "身分组", + "admin.sanction-ban": "封禁", + "admin.sanction-edit": "禁止编辑", + "admin.sanction-in-force": "生效中", + "admin.sanction-mute": "禁言", + "admin.sanction-rating": "禁止评分", + "admin.sanction-reason": "理由", + "admin.sanction-until": "到期时间", + "admin.sanctions": "本站处置", + "admin.sanctions-hint": "以下处置只在本站点生效。留空到期时间表示长期有效,填写后到期自动解除。", + "admin.save": "保存", + "admin.search": "搜索", + "admin.search-pages": "页面名或标题", + "admin.search-tags": "标签名", + "admin.search-users": "用户名、显示名或邮箱", + "admin.send": "发送", + "admin.setting-follow": "跟随站点({mode})", + "admin.settings": "设置", + "admin.sign-out": "登出", + "admin.site": "站点设置", + "admin.site-accounts": "注册与账号", + "admin.site-bad-domain": "文章域名与文件域名都要填成请求到达时的主机名,不带协议、路径和结尾的点", + "admin.site-bad-language": "所选语言没有对应的文案。", + "admin.site-bad-mode": "评分系统或标签设置的取值不对", + "admin.site-bad-policy": "邮箱验证要求的取值不对", + "admin.site-bad-slug": "站点缩写只能包含英文字母、数字、- 和 _", + "admin.site-bad-time-zone": "认不出这个时区名。请填 IANA 时区名,例如 Asia/Shanghai 或 UTC。", + "admin.site-content": "内容默认值", + "admin.site-identity": "站点信息", + "admin.site-look": "外观", + "admin.site-membership": "密码入组", + "admin.site-no-home": "主页名称不能为空", + "admin.site-no-title": "站点标题不能为空", + "admin.superuser": "超级管理员", + "admin.suspicious": "可疑活动", + "admin.tag-bad-slug": "标识名只能包含英文字母、数字、- 和 _", + "admin.tag-categories": "标签分类", + "admin.tag-no-name": "名称不能为空", + "admin.tagmode-disabled": "不允许", + "admin.tagmode-enabled": "允许", + "admin.tags": "标签", + "admin.theme-bad-mode": "类型只能是 inline 或 external", + "admin.theme-bad-slug": "标识名只能包含英文字母、数字、- 和 _", + "admin.theme-mode-external": "外部链接", + "admin.theme-mode-inline": "内嵌 CSS", + "admin.theme-no-name": "主题名称不能为空", + "admin.theme-no-url": "选了外部链接就必须填链接地址", + "admin.theme-settings": "主题设置", + "admin.themes": "主题", + "admin.ticket-content": "工单内容", + "admin.ticket-status-approved": "已通过", + "admin.ticket-status-closed": "已关闭", + "admin.ticket-status-pending": "待处理", + "admin.ticket-status-rejected": "已拒绝", + "admin.tickets": "支持工单", + "admin.title": "站点管理", + "admin.user-activity": "查看动态", + "admin.user-identity": "身分", + "admin.user-no-name": "用户名不能为空", + "admin.user-state": "状态与限制", + "admin.user-type-bot": "机器人", + "admin.user-type-normal": "普通用户", + "admin.user-type-system": "系统用户", + "admin.user-type-wikidot": "Wikidot 用户", + "admin.users": "用户", + "admin.view-all": "查看全部", + "admin.warn-delete": "页面会连同它的历史、附件和评分会一起删掉。无法撤销。", + "admin.warn-revert": "回退会连同标题、标签、父页面、评分和附件一起还原。", + "admin.yes": "是", + "api-bad-json": "请求体中的JSON格式无效", + "api-bad-notification-type": "无效的通知类型", + "api-bad-page-id": "无效的页面ID", + "api-bad-range": "无效的列表范围参数", + "api-bad-request": "无效请求", + "api-bad-revision": "无效的版本号", + "api-bad-subscription": "无效的订阅参数", + "api-blocked-by-recipient": "你已被对方拉黑", + "api-cannot-block-self": "不能拉黑自己", + "api-cannot-message-self": "不能给自己发私信", + "api-cannot-report-self": "不能检举自己", + "api-csrf-failed": "CSRF 校验失败", + "api-email-taken": "该邮箱的用户已存在", + "api-empty-message": "消息内容不能为空", + "api-file-exists": "同名文件已存在", + "api-file-not-found": "文件不存在", + "api-forbidden": "权限不足", + "api-internal-error": "服务器内部错误", + "api-login-required": "请先登录", + "api-message-too-long": "消息内容不能超过 {max} 个字符", + "api-messages-not-in-conversation": "部分消息无效或不属于该会话", + "api-messaging-disabled": "你的私信功能已被管理员禁用", + "api-missing-email": "未指定邮箱", + "api-missing-file-name": "缺少文件名", + "api-missing-reason": "请填写检举理由", + "api-missing-recipient": "缺少收件人", + "api-missing-reported": "缺少被检举人", + "api-missing-source": "缺少页面源代码", + "api-missing-title": "缺少页面标题", + "api-missing-username": "请输入用户名", + "api-no-message-permission": "你没有发送私信的权限", + "api-no-messages-picked": "请至少选择一条消息", + "api-page-exists": "此ID的页面已存在", + "api-page-not-found": "页面未找到", + "api-reason-too-long": "检举理由不能超过 {max} 个字符", + "api-recipient-inactive": "该用户已被禁用", + "api-report-not-found": "检举不存在", + "api-report-party-gone": "会话双方之一已被删除,无法查看完整记录", + "api-report-rate-limited": "24 小时内对同一用户的检举次数过多,请稍后再试", + "api-source-too-long": "超过页面大小限制", + "api-subscription-not-found": "订阅不存在", + "api-subscription-target-missing": "订阅的对象不存在", + "api-too-many-messages": "单次最多检举 {max} 条消息", + "api-upload-too-large": "超过文件上传限制", + "api-user-not-found": "用户不存在", + "button-copy-clipboard": "复制", + "collapsible-hide": "- 关闭折叠", + "collapsible-open": "+ 打开折叠", + "email.activate-body": "{name} 你好,\n\n点击下方的链接,该邮箱就会成为你在 {site} 中的绑定邮箱:\n\n{link}\n\n在你点击之前,原来的邮箱仍然有效。\n\n此致,\n{site}\n", + "email.activate-subject": "启用 {site} 的新邮箱", + "email.approve-body": "{name} 你好,\n\n有人想要将你在 {site} 的账号改绑到 {email}。如果这是你本人的操作,请点击下面的链接继续:\n\n{link}\n\n如果不是你,不要点击这个链接,并立刻修改密码。\n\n此致,\n{site}\n", + "email.approve-subject": "确认更换 {site} 的绑定邮箱", + "email.approved": "确认收到。新邮箱会收到一封启用邮件,点击之后改绑才会生效。", + "email.approved-title": "已确认", + "email.changed": "新邮箱已经生效并通过验证。", + "email.changed-title": "邮箱已更换", + "email.dead": "这个链接已经被使用过或者过期,请回到设置页重新发起。", + "email.dead-title": "链接已失效", + "email.invite-body": "你好,\n\n有人邀请你加入 {site}。点击下面的链接即可接受邀请并设置密码:\n\n{link}\n\n如果你不想接受,忽略这封邮件即可。\n\n此致,\n{site}\n", + "email.invite-subject": "邀请你加入 {site}", + "email.moved-body": "{name} 你好,\n\n你在 {site} 的绑定邮箱刚刚被更改为了 {email}。\n\n如果这是你本人的操作,无需理会这封邮件。\n\n如果不是你,请点击下面的链接立刻撤销该改绑操作。撤销之后账号上的所有登录状态和密码都会作废,你需要重新设置密码:\n\n{link}\n\n此致,\n{site}\n", + "email.moved-subject": "{site} 的绑定邮箱已被更换", + "email.password-body": "{name} 你好,\n\n你在 {site} 的密码刚刚遭到修改。\n\n如果这不是你本人的操作,立刻用下面的地址重置密码:\n\n{link}\n\n此致,\n{site}\n", + "email.password-subject": "{site} 的密码已修改", + "email.reverted": "邮箱已经更改回原先的地址,账号上的登录状态和密码都已作废。请立刻重新设置密码。", + "email.reverted-link": "去设置新密码", + "email.reverted-title": "邮箱已改回", + "email.taken": "这个邮箱已经被另一个账号验证并占用了,请换一个。", + "email.taken-title": "邮箱已被占用", + "email.verified": "这个邮箱已经和你的账号绑定,现在可以用它找回密码了。", + "email.verified-title": "邮箱已验证", + "email.verify-body": "{name} 你好,\n\n点击下方的链接即可验证这个邮箱。验证之后你才能使用它进行找回密码操作:\n\n{link}\n\n如果这不是你本人发起的,忽略这封邮件即可。\n\n此致,\n{site}\n", + "email.verify-subject": "验证你在 {site} 的邮箱", + "favourite-signed-out": "请先登录再收藏", + "favourite-toggle": "收藏", + "footnote": "脚注", + "footnote-block-title": "脚注", + "forum-like-signed-out": "请先登录再点赞", + "forum-like-toggle": "点赞", + "forum-like-who": "查看点赞的人", + "host.unresolved": "这台服务器上没有绑定到 {host} 的站点。检查一下域名有没有填错,或者 DNS 是不是还没生效。", + "host.unresolved-title": "没有这个站点", + "image-context-bad": "图像地址不正确", + "include-create": "立刻创建", + "include-loop": "插入的页面 \"{name}\" 导致了无限包含循环", + "include-missing": "插入的页面 \"{name}\" 不存在", + "include-off-site": "取不到插入的页面 \"{name}\",可能是站点缩写写错了,也可能是你在那个站点上看不到它", + "language-name": "简体中文", + "login.error-credentials": "用户名或密码错误。请重试。", + "login.forgot": "忘记密码", + "login.no-account": "还没有账号", + "login.password": "密码", + "login.signup": "注册", + "login.submit": "登 录", + "login.title": "登录账号", + "login.username": "用户名", + "math-too-complex": "公式过长或嵌套过深,无法显示", + "module-applicationform-body": "正文", + "module-applicationform-login": "请先登录后再提交。", + "module-applicationform-subject": "标题", + "module-applicationform-submit": "提交", + "module-applied-failed": "提交失败,请检查填写的内容。", + "module-applied-ok": "已提交,等待管理员处理。", + "module-button-unknown": "不支持的按钮类型:{type}", + "module-comments-hide": "隐藏评论", + "module-comments-show": "显示评论", + "module-date-format": "{month}.{day}.{year} {hour}:{minute} ({zone})", + "module-date-format-js": "%m.%d.%Y %H:%M", + "module-date-missing": "n/a", + "module-disabled": "模块处理已禁用", + "module-failed": "处理模块 '{name}' 时出错", + "module-files-manage": "管理附件", + "module-files-name": "文件名称", + "module-files-size": "大小", + "module-files-type": "文件类型", + "module-forum-author": "作者:", + "module-forum-last-post": "最后回复", + "module-forum-new-thread": "创建主题", + "module-forum-not-found": "未找到版块 \"{name}\"", + "module-forum-posts": "帖子数:", + "module-forum-threads": "主题数:", + "module-forum-title": "论坛", + "module-forum-title-named": "论坛 — {name}", + "module-forum-view-post": "查看", + "module-forumcategory-forbidden": "权限不足,无法查看该版块", + "module-forumcategory-not-given": "版块未找到或未指定", + "module-forumcategory-pinned": "置顶:", + "module-forumcategory-replies": "回复数", + "module-forumcategory-sort": "排序方式:", + "module-forumcategory-sort-reply": "按最后回复时间", + "module-forumcategory-sort-start": "按主题创建时间", + "module-forumcategory-started": "创建信息", + "module-forumcategory-thread": "主题标题", + "module-forumnewthread-forbidden": "权限不足,无法创建主题", + "module-forumpost-cannot-create": "权限不足,无法创建帖子", + "module-forumpost-cannot-delete": "权限不足,无法删除帖子", + "module-forumpost-cannot-edit": "权限不足,无法编辑帖子", + "module-forumpost-cannot-view": "权限不足,无法查看帖子", + "module-forumpost-missing": "帖子 \"{id}\" 不存在", + "module-forumpost-no-source": "未提供帖子内容", + "module-forumpost-other-thread": "无法回复其他主题中的帖子", + "module-forumstart-hide-hidden": "隐藏隐藏版块", + "module-forumstart-posts": "帖子数", + "module-forumstart-section": "版块名称", + "module-forumstart-show-hidden": "显示隐藏版块", + "module-forumstart-threads": "主题数", + "module-forumstart-title": "论坛版块", + "module-forumthread-article": "这是页面 {link} 的讨论", + "module-forumthread-cannot-create": "权限不足,无法创建主题", + "module-forumthread-cannot-edit": "权限不足,无法编辑主题", + "module-forumthread-cannot-lock": "权限不足,无法锁定主题", + "module-forumthread-cannot-move": "权限不足,无法移动主题", + "module-forumthread-cannot-pin": "权限不足,无法置顶主题", + "module-forumthread-comments-forbidden": "权限不足,无法查看讨论", + "module-forumthread-created-by": "创建者:", + "module-forumthread-date": "日期:", + "module-forumthread-description": "简短描述:", + "module-forumthread-forbidden": "权限不足,无法查看主题", + "module-forumthread-no-page": "未指定页面", + "module-forumthread-no-source": "未提供首帖内容", + "module-forumthread-no-title": "未指定主题标题", + "module-forumthread-not-found": "未找到主题 \"{name}\"", + "module-forumthread-not-given": "主题未找到或未指定", + "module-forumthread-posts": "帖子数:", + "module-frontforum-no-category": "[[module FrontForum]] 缺少 category 参数", + "module-gallery-size": "不支持的图库尺寸:{size}", + "module-input-no-fields": "[[input]] 没有定义任何字段", + "module-listpages-pager-count": "第 {page} 页; 共 {total} 页", + "module-listpages-pager-next": "下一页 »", + "module-listpages-pager-prev": "« 上一页", + "module-listusers-anonymous": "@@[匿名]@@", + "module-members-no-role": "身分组 \"{name}\" 不存在", + "module-membership-failed": "密码不正确。", + "module-membership-ok": "密码正确,身分组已发放。", + "module-membershipbypassword-login": "请先登录后再输入密码。", + "module-membershipbypassword-password": "输入密码", + "module-membershipbypassword-submit": "提交", + "module-newpage-bad-name": "此页面名称不可用", + "module-newpage-no-name": "请输入页面名称", + "module-newpage-submit": "创建页面", + "module-newpage-taken": "此页面已存在", + "module-pagesbytag-category": " 来自分类 {category}", + "module-pagesbytag-heading": "标记为 {tag} 的页面列表{category}:", + "module-rate-bad-value": "无效的评分 {value}", + "module-rate-cancel": "取消", + "module-rate-down": "不喜欢", + "module-rate-forbidden": "权限不足", + "module-rate-label": "评分:", + "module-rate-no-page": "未指定页面", + "module-rate-no-value": "未指定评分值", + "module-rate-popularity": "人气值(3.0分及以上评分占比)", + "module-rate-up": "喜欢", + "module-rate-votes": "评分数", + "module-recentposts-all": "所有版块", + "module-recentposts-from": "来自", + "module-recentposts-goto": "跳转到帖子", + "module-recentposts-refresh": "刷新", + "module-recentposts-select": "选择版块:", + "module-recentposts-title": "论坛最新帖子", + "module-search-placeholder": "搜索文章标题或内容…", + "module-sitechanges-category": "选择分类:", + "module-sitechanges-category-all": "所有分类", + "module-sitechanges-comment-authors-added": "添加作者:{names}。", + "module-sitechanges-comment-authors-removed": "移除作者:{names}。", + "module-sitechanges-comment-file-added": "已上传文件:\"{name}\"", + "module-sitechanges-comment-file-deleted": "已删除文件:\"{name}\"", + "module-sitechanges-comment-file-renamed": "文件已从 \"{prev}\" 重命名为 \"{name}\"", + "module-sitechanges-comment-name": "页面已从 \"{prev}\" 重命名为 \"{name}\"", + "module-sitechanges-comment-new": "创建新页面", + "module-sitechanges-comment-parent-changed": "父页面已从 \"{prev}\" 更改为 \"{parent}\"", + "module-sitechanges-comment-parent-removed": "已移除父页面 \"{prev}\"", + "module-sitechanges-comment-parent-set": "已设置父页面 \"{parent}\"", + "module-sitechanges-comment-revert": "已回退页面至版本 #{rev}", + "module-sitechanges-comment-tags-added": "添加标签:{tags}。", + "module-sitechanges-comment-tags-removed": "移除标签:{tags}。", + "module-sitechanges-comment-title": "标题已从 \"{prev}\" 更改为 \"{title}\"", + "module-sitechanges-comment-votes": "已重置页面评分:{rating}(投票数:{votes},人气:{popularity}%)", + "module-sitechanges-comment-votes-none": "无", + "module-sitechanges-perpage": "每页条目数:", + "module-sitechanges-refresh": "刷新列表", + "module-sitechanges-revision": "版本", + "module-sitechanges-type-authorship": "作者已更改", + "module-sitechanges-type-file-added": "文件已添加", + "module-sitechanges-type-file-deleted": "文件已删除", + "module-sitechanges-type-file-renamed": "文件已重命名", + "module-sitechanges-type-name": "页面已重命名/删除", + "module-sitechanges-type-new": "新页面已创建", + "module-sitechanges-type-parent": "父页面已更改", + "module-sitechanges-type-source": "文章内容已更改", + "module-sitechanges-type-tags": "标签已更改", + "module-sitechanges-type-title": "标题已更改", + "module-sitechanges-type-votes-deleted": "投票已更改", + "module-sitechanges-type-wikidot": "从Wikidot移植的编辑", + "module-sitechanges-types": "编辑类型:", + "module-sitechanges-types-all": "全部", + "module-sitechanges-username": "按用户名筛选:", + "module-sitechanges-username-hint": "使用前缀\"~\"进行部分
      用户名匹配筛选", + "module-tagcloud-color": "无效的颜色:{color}", + "module-tagcloud-font-size": "无效的字体大小:{size}", + "module-tagcloud-units": "最大和最小字体大小的单位不同:{min} 和 {max}", + "module-unknown": "模块 '{name}' 不存在", + "module-wantedpages-missing": "缺失的链接", + "module-wantedpages-source": "源页面", + "page.date-format": "{month}月{day}日, {year}年 {hour}:{minute} ({zone})", + "page.date-format-js": "%b%e日, %Y年 %H:%M (%O)", + "page.forbidden": "您没有查看此页面的权限 {page}.", + "page.license": "除非特别注明,本页内容采用以下授权方式: {link}.", + "page.not-found": "您请求的页面 {page} 不存在", + "page.page-info": "页面版本: {rev}, 最后编辑: {date}", + "page.search-placeholder": "搜索本站", + "page.search-submit": "搜索", + "password.all-numeric": "密码只包含数字。", + "password.too-common": "这个密码太常见了。", + "password.too-short": "这个密码太短了。密码至少包含 8 个字符。", + "password.too-similar": "密码跟个人信息太相似了。", + "profile.account-state": "账号状态", + "profile.account-type": "账号类型", + "profile.advanced-editor": "高级源代码编辑器", + "profile.advanced-editor-hint": "编辑时使用带语法高亮的编辑器", + "profile.avatar": "头像", + "profile.back": "返回我的资料页", + "profile.bio": "个人简介", + "profile.bio-hint": "支持 Wiki 语法", + "profile.block": "拉黑", + "profile.bot": "机器人", + "profile.date-format": "{year} 年 {month} 月 {day} 日", + "profile.date-format-js": "%b%e日, %Y年 %H:%M", + "profile.edit": "编辑资料", + "profile.edits": "编辑履历", + "profile.email-new": "邮箱地址", + "profile.email-none": "还没有绑定邮箱", + "profile.email-password": "当前密码", + "profile.email-password-hint": "换成另一个邮箱时需要;只是验证现在这个可以留空", + "profile.email-pending": "等待 {email} 确认", + "profile.email-section": "邮箱", + "profile.email-submit": "保存邮箱", + "profile.email-unverified": "未验证 —— 验证之后才能用它找回密码", + "profile.email-verified": "已验证", + "profile.error-avatar-size": "头像文件太大,请压到 5 MB 以内。", + "profile.error-avatar-type": "头像必须是 PNG、JPEG、GIF 或 WebP 图片。", + "profile.error-language": "所选语言没有对应的文案。", + "profile.imported": "从 Wikidot 平台迁移而来的非活跃用户", + "profile.joined": "加入时间", + "profile.language": "界面语言", + "profile.language-auto": "跟随浏览器", + "profile.name": "姓名", + "profile.name-hint": "三十天只能改一次,改完之后旧的个人页地址和文章中的[[user]]语法都会失效", + "profile.name-new": "新用户名", + "profile.name-section": "修改用户名", + "profile.name-submit": "修改用户名", + "profile.no-edits": "还没有编辑过任何页面。", + "profile.no-posts": "还没有发过任何帖子。", + "profile.password-again": "再次输入新密码", + "profile.password-current": "当前密码", + "profile.password-forgot": "忘记当前密码?", + "profile.password-new": "新密码", + "profile.password-section": "修改密码", + "profile.password-submit": "修改密码", + "profile.personal": "个人", + "profile.posts": "发帖履历", + "profile.save": "保存", + "profile.saved": "已保存", + "profile.send-message": "发私信", + "profile.site-roles": "站点身分组", + "profile.title": "个人资料", + "profile.unblock": "取消拉黑", + "profile.user": "用户", + "reset.ask": "忘记密码?请输入电子邮箱地址,我们将向您发送密码重置说明。", + "reset.back-login": "返回登录", + "reset.back-reset": "返回找回密码", + "reset.dead": "您点击的链接似乎已失效,请重试。", + "reset.dead-again": "重新发起", + "reset.dead-title": "无效链接", + "reset.done": "您的密码已成功修改。您现在可以继续并登录账号。", + "reset.done-title": "密码已重置", + "reset.email": "邮箱地址", + "reset.error-mismatch": "两次输入的密码不一致。", + "reset.help-default": "请联系您的网站管理员。", + "reset.help-title": "收不到邮件", + "reset.help-toggle": "收不到邮件?", + "reset.login": "登录", + "reset.mail-body": "你好,\n\n我们收到了与你邮箱关联的账户的密码重置请求。要开始重置你的账户密码,请点击下面的链接:\n\n{link}\n\n此链接仅能使用一次。如果你需要再次重置密码,请访问 {home} 并重新发起重置请求。\n\n如果你未请求重置密码,请忽略此邮件。\n\n此致,\n{site}\n", + "reset.mail-subject": "{site} 的密码重置", + "reset.new-password": "新密码", + "reset.new-password-again": "再次输入新密码", + "reset.send": "发送邮件", + "reset.sent": "密码重置说明已发送至您的邮箱,请查收。", + "reset.sent-check": "如果未收到邮件,请确认邮箱地址是否正确,并检查邮件垃圾箱。", + "reset.sent-title": "邮件已发送", + "reset.set": "请输入新密码。", + "reset.set-title": "设置新密码", + "reset.submit": "提交", + "reset.title": "重置密码", + "settings.email-approval-sent": "确认邮件已发送到当前邮箱。确认之后新邮箱才会收到启用邮件。", + "settings.email-invalid": "请填写一个可用的邮箱地址。", + "settings.email-needs-password": "当前邮箱还没有验证,更换邮箱必须输入当前密码。", + "settings.email-same": "这就是当前已验证的邮箱,没有任何变化。", + "settings.email-sent": "启用邮件已发送到新邮箱,点击链接后改绑才会生效。", + "settings.email-taken": "这个邮箱已经被另一个账号验证并占用了。", + "settings.email-verified-sent": "验证邮件已重新发送,请查收。", + "settings.name-changed": "用户名已修改。", + "settings.name-cooldown": "距离上次改名不满三十天。", + "settings.name-invalid": "这个用户名不可用。", + "settings.name-taken": "这个用户名已经有人用了。", + "settings.password-changed": "密码已修改。", + "settings.password-mismatch": "两次输入的新密码不一致。", + "settings.password-unusable": "这个账号现在没有可用的密码,请走找回密码。", + "settings.password-weak": "新密码不符合要求。", + "settings.password-wrong": "当前密码不正确。", + "signup.code": "Wikit 验证码", + "signup.code-failed": "发送失败,请稍后重试", + "signup.code-placeholder": "请输入验证码", + "signup.code-sending": "发送中…", + "signup.code-sent": "验证码已发送,请前往 Wikidot 站内信查收。", + "signup.email": "邮箱", + "signup.email-hint": "用于找回密码", + "signup.error-code-empty": "请输入验证码", + "signup.error-code-wrong": "验证码错误,请重试", + "signup.error-email-invalid": "请填写一个可用的邮箱地址", + "signup.error-email-taken": "该邮箱已被其他账号验证并占用", + "signup.error-name-empty": "显示名不能为空。", + "signup.error-name-invisible": "显示名不能包含控制符、零宽字符等不可见字符。", + "signup.error-name-mark": "显示名不能以组合记号开头。", + "signup.error-name-reserved": "该用户名是保留形式,请换一个。", + "signup.error-name-space": "显示名中的空格只能是普通空格。", + "signup.error-name-taken": "用户名已被使用({name} 与已有用户的身份名相同)", + "signup.error-name-too-long": "显示名不能超过 50 个字符。", + "signup.error-not-wikidot": "该用户名不是待认领的 Wikidot 账号", + "signup.error-password-mismatch": "两次输入的密码不一致", + "signup.error-username-empty": "用户名不能为空", + "signup.error-verify-unreachable": "无法连接到验证服务,请稍后重试", + "signup.have-account": "已有账号 → 登录", + "signup.heading": "创建账号", + "signup.network-error": "网络错误,请稍后重试", + "signup.notice": "普通注册默认分配「读者」权限", + "signup.password": "设置密码", + "signup.password-confirm": "再次输入密码", + "signup.password-confirm-placeholder": "确认一致", + "signup.password-placeholder": "至少 8 位", + "signup.resend": "重新发送 ({seconds}s)", + "signup.send-code": "发送验证码", + "signup.sent": "验证链接已发送到 {email},点击链接后即可登录。", + "signup.sent-title": "去邮箱确认", + "signup.submit": "立 即 注 册", + "signup.submit-claim": "认 领 账 号", + "signup.title": "用户注册", + "signup.username": "用户名", + "signup.username-placeholder": "认领 Wikidot 账号请填写原名", + "signup.wikidot-detected": "检测到此用户名对应一个待认领的 Wikidot 账号,需要验证码", + "system.back-home": "返回网站首页", + "table-of-contents": "内容", + "toc-close": "关闭", + "toc-open": "展开", + "update.banner": "本站预计于 {time} 开始自动更新,届时可能短暂无法访问。", + "update.banner-close": "关闭", + "update.mail-rolled-back-body": "实例 {root} 在 {time} 尝试从 {from} 更新到 {version},但新版本未能正常运行,已自动回档到 {from},网站现已恢复。\n\n失败原因:\n{error}\n\n该版本不会再自动安装。详细过程记录在 {log}。修复后可执行 pwikit update 手动重试。", + "update.mail-rolled-back-subject": "自动更新到 {version} 失败,已回档到 {from}", + "update.maintenance": "本站正在更新到新版本,通常几分钟内即可恢复。请稍后刷新页面。", + "update.maintenance-title": "正在更新", + "update.notes": "查看更新说明", + "update.notice-available": "有新版本 {version} 可用。", + "update.notice-rolled-back": "{time} 更新到 {version} 失败,已回档到 {from}。该版本不会再自动安装。", + "update.notice-scheduled": "预计于 {time} 自动更新到 {version},更新期间网站会短暂无法访问。", + "update.notice-updated": "已从 {from} 更新到 {version}。", + "update.postpone": "推迟 24 小时", + "update.reason-auto-off": "自动更新已关闭,请执行 pwikit update 手动更新。", + "update.reason-check-off": "检查新版本已关闭。", + "update.reason-container": "当前实例运行在容器中,请拉取新的镜像进行更新。", + "update.reason-failed": "{detail} 此前更新失败并已回档,不会再自动安装;如需重试请执行 pwikit update。", + "update.reason-newest": "当前已是最新版本。", + "update.reason-no-number": "当前程序不是正式版本,不会自动更新。", + "update.reason-pinned": "已固定在 {detail},执行 pwikit update unpin 解除固定后才会自动更新。", + "update.reason-postgres": "该版本会将内置 PostgreSQL 升级到 {detail},需要执行 pwikit update 手动更新。", + "update.reason-postponed": "自动更新已推迟,推迟结束后会在下一个更新时段重新安排。", + "update.reason-skipped": "已跳过 {detail},如需安装请执行 pwikit update。", + "update.reason-too-new": "该版本发布未满 {detail} 小时,届时会自动安排更新。", + "update.reason-unknown": "尚未获取到新版本信息。", + "update.skip": "跳过此版本", + "user-anonymous": "匿名用户", + "user-banned": "封禁", + "user-banned-title": "已封禁", + "user-banned-tooltip": "用户已被封禁", + "user-bot": "机器人", + "user-bot-title": "机器人", + "user-bot-tooltip": "机器账户", + "user-deleted": "(已删除)", + "user-inactive-title": "未激活", + "user-not-found": "用户 '{name}' 不存在", + "user-system": "系统" +} diff --git a/internal/i18n/negotiate.go b/internal/i18n/negotiate.go new file mode 100644 index 00000000..d94450f2 --- /dev/null +++ b/internal/i18n/negotiate.go @@ -0,0 +1,111 @@ +package i18n + +import ( + "context" + "fmt" + + "golang.org/x/text/language" +) + +const AcceptHeader = "Accept-Language" + +type languageKey struct{} + +func WithLanguage(ctx context.Context, lang string) context.Context { + return context.WithValue(ctx, languageKey{}, Normalize(lang)) +} + +// LanguageFrom is empty for a request that never met the negotiator, which +// Localizer already reads as the default language. +func LanguageFrom(ctx context.Context) string { + lang, _ := ctx.Value(languageKey{}).(string) + return lang +} + +func (b *Bundle) For(ctx context.Context) *Localizer { + return b.Localizer(LanguageFrom(ctx)) +} + +// A chosen language outranks the browser's, because a browser speaks for a +// device and a member speaks for themselves. +func (b *Bundle) Negotiate(chosen, accept, siteDefault string) string { + if b.Has(chosen) { + return Normalize(chosen) + } + if lang := b.Match(accept); lang != "" { + return lang + } + if b.Has(siteDefault) { + return Normalize(siteDefault) + } + return DefaultLanguage +} + +// Match is empty when the header names nothing the bundle carries, so the +// caller can tell "no opinion" apart from "asked for the default". +func (b *Bundle) Match(accept string) string { + if accept == "" { + return "" + } + wanted, _, err := language.ParseAcceptLanguage(accept) + if err != nil || len(wanted) == 0 { + return "" + } + _, index, conf := b.matcher.Match(wanted...) + if conf == language.No || index >= len(b.tagged) { + return "" + } + return b.tagged[index] +} + +type Choice struct { + Tag string + Name string +} + +func (b *Bundle) Choices() []Choice { + out := make([]Choice, 0, len(b.tagged)) + for _, tag := range b.tagged { + out = append(out, Choice{Tag: tag, Name: b.Name(tag)}) + } + return out +} + +// A catalog names its own language, so adding one is a JSON file and nothing +// else. +func (b *Bundle) Name(lang string) string { + lang = Normalize(lang) + if name := b.catalogs[lang][nameKey]; name != "" { + return name + } + return lang +} + +const nameKey = "language-name" + +// The default language goes first because that is the tag a matcher answers +// with when it recognises nothing. +func (b *Bundle) buildMatcher() error { + names := b.Languages() + b.tagged = append([]string{DefaultLanguage}, remove(names, DefaultLanguage)...) + tags := make([]language.Tag, 0, len(b.tagged)) + for _, name := range b.tagged { + tag, err := language.Parse(name) + if err != nil { + return fmt.Errorf("catalog %q is not a language tag: %w", name, err) + } + tags = append(tags, tag) + } + b.matcher = language.NewMatcher(tags) + return nil +} + +func remove(names []string, drop string) []string { + out := make([]string, 0, len(names)) + for _, name := range names { + if name != drop { + out = append(out, name) + } + } + return out +} diff --git a/internal/i18n/negotiate_test.go b/internal/i18n/negotiate_test.go new file mode 100644 index 00000000..57556b08 --- /dev/null +++ b/internal/i18n/negotiate_test.go @@ -0,0 +1,82 @@ +package i18n + +import ( + "context" + "testing" +) + +func english(t *testing.T) *Bundle { + t.Helper() + dir := t.TempDir() + writeCatalog(t, dir, "en", map[string]string{"toc-open": "Expand"}) + return load(t, dir) +} + +func TestMatchReadsAcceptLanguage(t *testing.T) { + b := english(t) + for _, c := range []struct{ accept, want string }{ + {"en", "en"}, + {"en-US,en;q=0.9", "en"}, + {"fr;q=0.8,en;q=0.9", "en"}, + {"zh-CN,zh;q=0.9", "zh-hans"}, + } { + if got := b.Match(c.accept); got != c.want { + t.Errorf("Match(%q) = %q, want %q", c.accept, got, c.want) + } + } +} + +func TestMatchIsEmptyWithoutAKnownLanguage(t *testing.T) { + b := english(t) + for _, accept := range []string{"", "de", "garbage!!"} { + if got := b.Match(accept); got != "" { + t.Errorf("Match(%q) = %q, want %q", accept, got, "") + } + } +} + +func TestNegotiatePrefersTheChosenLanguage(t *testing.T) { + if got := english(t).Negotiate("en", "zh-CN", DefaultLanguage); got != "en" { + t.Errorf("Negotiate(%q, %q, %q) = %q, want %q", "en", "zh-CN", DefaultLanguage, got, "en") + } +} + +func TestNegotiateAsksTheBrowserWhenNothingWasChosen(t *testing.T) { + if got := english(t).Negotiate("", "en-GB", DefaultLanguage); got != "en" { + t.Errorf("Negotiate(%q, %q, %q) = %q, want %q", "", "en-GB", DefaultLanguage, got, "en") + } +} + +func TestNegotiateFallsBackToTheSiteLanguage(t *testing.T) { + if got := english(t).Negotiate("", "de", "en"); got != "en" { + t.Errorf("Negotiate(%q, %q, %q) = %q, want %q", "", "de", "en", got, "en") + } +} + +func TestNegotiateIgnoresLanguagesTheBundleLacks(t *testing.T) { + if got := english(t).Negotiate("de", "de", "de"); got != DefaultLanguage { + t.Errorf("Negotiate(%q, %q, %q) = %q, want %q", "de", "de", "de", got, DefaultLanguage) + } +} + +func TestForReadsTheLanguageOffTheContext(t *testing.T) { + b := english(t) + ctx := WithLanguage(context.Background(), "EN") + if got := b.For(ctx).Lang(); got != "en" { + t.Errorf("For(ctx).Lang() = %q, want %q", got, "en") + } +} + +func TestForFallsBackWithoutALanguageOnTheContext(t *testing.T) { + if got := english(t).For(context.Background()).Lang(); got != DefaultLanguage { + t.Errorf("For(ctx).Lang() = %q, want %q", got, DefaultLanguage) + } +} + +func TestLoadRejectsACatalogThatIsNotALanguageTag(t *testing.T) { + dir := t.TempDir() + writeCatalog(t, dir, "not+a+tag", map[string]string{"toc-open": "Expand"}) + if _, err := Load(dir); err == nil { + t.Error("Load() err = nil, want non-nil") + } +} diff --git a/internal/lang/lang.go b/internal/lang/lang.go new file mode 100644 index 00000000..7b12a892 --- /dev/null +++ b/internal/lang/lang.go @@ -0,0 +1,39 @@ +// Package lang answers which language a request is served in. +package lang + +import ( + "context" + "net/http" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/respheader" + "github.com/WikitTeam/ProjectWikit/internal/site" +) + +// Middleware belongs inside the session resolver, since a member's own choice +// outranks everything the request carries. +func Middleware(bundle *i18n.Bundle) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + serve := bundle.Negotiate(chosen(ctx), r.Header.Get(i18n.AcceptHeader), siteDefault(ctx)) + next.ServeHTTP(w, r.WithContext(i18n.WithLanguage(ctx, serve))) + }) + return respheader.VaryLanguage(inner) + } +} + +func chosen(ctx context.Context) string { + if user := auth.FromContext(ctx); user != nil { + return user.Language + } + return "" +} + +func siteDefault(ctx context.Context) string { + if current := site.FromContext(ctx); current != nil { + return current.Language + } + return "" +} diff --git a/internal/lang/lang_test.go b/internal/lang/lang_test.go new file mode 100644 index 00000000..70d12377 --- /dev/null +++ b/internal/lang/lang_test.go @@ -0,0 +1,91 @@ +package lang + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/site" +) + +func bundle(t *testing.T) *i18n.Bundle { + t.Helper() + dir := t.TempDir() + raw, err := json.Marshal(map[string]string{"toc-open": "Expand"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "en.json"), raw, 0o644); err != nil { + t.Fatal(err) + } + b, err := i18n.Load(dir) + if err != nil { + t.Fatalf("Load() err = %v, want nil", err) + } + return b +} + +func serve(t *testing.T, accept string, current *db.Site, user *db.User) (string, http.Header) { + t.Helper() + var chosen string + handler := Middleware(bundle(t))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + chosen = i18n.LanguageFrom(r.Context()) + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + if accept != "" { + req.Header.Set(i18n.AcceptHeader, accept) + } + ctx := req.Context() + if current != nil { + ctx = site.WithSite(ctx, current) + } + if user != nil { + ctx = auth.NewContext(ctx, user) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req.WithContext(ctx)) + return chosen, rec.Header() +} + +func TestMiddlewarePrefersTheMemberChoice(t *testing.T) { + got, _ := serve(t, "zh-CN", &db.Site{Language: "zh-hans"}, &db.User{Language: "en"}) + if got != "en" { + t.Errorf("LanguageFrom(ctx) = %q, want %q", got, "en") + } +} + +func TestMiddlewareFallsBackToTheBrowser(t *testing.T) { + got, _ := serve(t, "en-GB", &db.Site{Language: "zh-hans"}, &db.User{}) + if got != "en" { + t.Errorf("LanguageFrom(ctx) = %q, want %q", got, "en") + } +} + +func TestMiddlewareFallsBackToTheSite(t *testing.T) { + got, _ := serve(t, "de", &db.Site{Language: "en"}, nil) + if got != "en" { + t.Errorf("LanguageFrom(ctx) = %q, want %q", got, "en") + } +} + +func TestMiddlewareWithoutASiteOrAUser(t *testing.T) { + got, _ := serve(t, "", nil, nil) + if got != i18n.DefaultLanguage { + t.Errorf("LanguageFrom(ctx) = %q, want %q", got, i18n.DefaultLanguage) + } +} + +func TestMiddlewareVariesOnAcceptLanguage(t *testing.T) { + _, header := serve(t, "en", nil, nil) + if got := header.Get("Vary"); got != "Accept-Language" { + t.Errorf("Vary = %q, want %q", got, "Accept-Language") + } +} diff --git a/internal/listpages/formquery.go b/internal/listpages/formquery.go new file mode 100644 index 00000000..fc7b6630 --- /dev/null +++ b/internal/listpages/formquery.go @@ -0,0 +1,134 @@ +package listpages + +import ( + "sort" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/form" +) + +func (q Query) hasFormWork() bool { return len(q.FormConds) > 0 || q.FormSort != "" } + +// Form fields live in the page source rather than in a column, so the rows the +// database can narrow are read one by one and the rest of the query is +// answered here. +func runWithForm(src Source, q Query, filter db.ListFilter, paginate bool) (Result, error) { + rows, err := src.ListArticles(filter, 0, nil) + if err != nil { + return Result{}, err + } + kept, err := selectByForm(src, q, rows) + if err != nil { + return Result{}, err + } + + kept = kept[min(q.Offset, len(kept)):] + if q.Limit != nil && *q.Limit < len(kept) { + kept = kept[:max(*q.Limit, 0)] + } + total := len(kept) + + if !paginate { + return Result{Pages: kept, Page: 1, TotalPages: 1, Total: total}, nil + } + if q.PerPage <= 0 { + return Result{Page: q.Page, TotalPages: 0, Total: total}, nil + } + start := min((q.Page-1)*q.PerPage, total) + end := min(start+q.PerPage, total) + return Result{ + Pages: kept[start:end], + PageIndex: (q.Page - 1) * q.PerPage, + Page: q.Page, + TotalPages: totalPages(total, q.PerPage), + Total: total, + }, nil +} + +func selectByForm(src Source, q Query, rows []db.Article) ([]db.Article, error) { + kept := make([]db.Article, 0, len(rows)) + keys := make(map[int64]string, len(rows)) + + for _, row := range rows { + values, def, err := formValuesOf(src, row) + if err != nil { + return nil, err + } + if !matchesAll(def, values, q.FormConds) { + continue + } + if q.FormSort != "" { + key, _ := def.Raw(values, q.FormSort) + keys[row.ID] = key + } + kept = append(kept, row) + } + + if q.FormSort != "" { + // Stable, so rows carrying the same value keep the order the database + // put them in. + sort.SliceStable(kept, func(i, j int) bool { + left, right := keys[kept[i].ID], keys[kept[j].ID] + if q.FormSortAsc { + return left < right + } + return left > right + }) + } + return kept, nil +} + +func formValuesOf(src Source, row db.Article) (map[string]string, *form.Definition, error) { + def, err := src.CategoryForm(row.Category) + if err != nil { + return nil, nil, err + } + if def == nil { + return nil, nil, nil + } + source, err := src.LatestSource(row.ID) + if err != nil { + // A page with no revision answers with whatever the form defaults to. + source = "" + } + values, err := form.ParseData(source) + if err != nil { + values = map[string]string{} + } + return values, def, nil +} + +// A row whose category carries no form has no value to compare, so a condition +// on one excludes it rather than matching an empty string. +func matchesAll(def *form.Definition, values map[string]string, conds []FormCond) bool { + for _, cond := range conds { + if def == nil { + return false + } + value, ok := def.Raw(values, cond.Field) + if !ok { + return false + } + if !compareForm(value, cond.Op, cond.Value) { + return false + } + } + return true +} + +func compareForm(value, op, want string) bool { + switch op { + case "<>": + return value != want + case "<": + return value < want + case ">": + return value > want + case "<=": + return value <= want + case ">=": + return value >= want + } + return strings.EqualFold(value, want) +} diff --git a/internal/listpages/formquery_test.go b/internal/listpages/formquery_test.go new file mode 100644 index 00000000..977d2da9 --- /dev/null +++ b/internal/listpages/formquery_test.go @@ -0,0 +1,231 @@ +package listpages + +import ( + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/form" +) + +const noticeForm = `[[form]] +fields: + Pinned: + type: checkbox + default: 0 + Rank: + type: text +[[/form]]` + +func noticeSource(t *testing.T) *fakeSource { + t.Helper() + def, _, err := form.Parse(noticeForm) + if err != nil { + t.Fatalf("form.Parse() err = %v, want nil", err) + } + return &fakeSource{ + forms: map[string]*form.Definition{"notice": def}, + listed: []db.Article{ + {ID: 1, Category: "notice", Name: "a"}, + {ID: 2, Category: "notice", Name: "b"}, + {ID: 3, Category: "notice", Name: "c"}, + }, + sources: map[int64]string{ + 1: "Pinned: '1'\nRank: '02'", + 2: "Pinned: '0'\nRank: '01'", + 3: "Rank: '03'", + }, + } +} + +func namesOf(pages []db.Article) []string { + out := make([]string, len(pages)) + for i := range pages { + out[i] = pages[i].Name + } + return out +} + +func sameNames(got []db.Article, want []string) bool { + names := namesOf(got) + if len(names) != len(want) { + return false + } + for i := range want { + if names[i] != want[i] { + return false + } + } + return true +} + +func TestRunFiltersOnAFormField(t *testing.T) { + src := noticeSource(t) + q := Query{ + Page: 1, PerPage: 20, + FormConds: []FormCond{{Field: "pinned", Op: "=", Value: "1"}}, + } + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"a"}) { + t.Errorf("Run().Pages = %v, want [a]", namesOf(got.Pages)) + } + if got.Total != 1 { + t.Errorf("Run().Total = %d, want 1", got.Total) + } +} + +func TestRunCountsAMissingValueAsTheDefault(t *testing.T) { + src := noticeSource(t) + q := Query{ + Page: 1, PerPage: 20, + FormConds: []FormCond{{Field: "Pinned", Op: "=", Value: "0"}}, + } + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"b", "c"}) { + t.Errorf("Run().Pages = %v, want [b c]", namesOf(got.Pages)) + } +} + +func TestRunComparesAFormFieldWithAnOperator(t *testing.T) { + src := noticeSource(t) + q := Query{ + Page: 1, PerPage: 20, + FormConds: []FormCond{{Field: "Rank", Op: ">", Value: "01"}}, + } + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"a", "c"}) { + t.Errorf("Run().Pages = %v, want [a c]", namesOf(got.Pages)) + } +} + +func TestRunDropsARowWhoseCategoryHasNoForm(t *testing.T) { + src := noticeSource(t) + src.listed = append(src.listed, db.Article{ID: 4, Category: "other", Name: "d"}) + q := Query{ + Page: 1, PerPage: 20, + FormConds: []FormCond{{Field: "Pinned", Op: "=", Value: "0"}}, + } + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"b", "c"}) { + t.Errorf("Run().Pages = %v, want [b c]", namesOf(got.Pages)) + } +} + +func TestRunSortsOnAFormField(t *testing.T) { + src := noticeSource(t) + q := Query{Page: 1, PerPage: 20, FormSort: "Rank", FormSortAsc: true} + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"b", "a", "c"}) { + t.Errorf("Run().Pages = %v, want [b a c]", namesOf(got.Pages)) + } +} + +func TestRunSortsOnAFormFieldDescending(t *testing.T) { + src := noticeSource(t) + q := Query{Page: 1, PerPage: 20, FormSort: "Rank"} + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"c", "a", "b"}) { + t.Errorf("Run().Pages = %v, want [c a b]", namesOf(got.Pages)) + } +} + +func TestRunPaginatesTheFilteredRows(t *testing.T) { + src := noticeSource(t) + q := Query{ + Page: 2, PerPage: 1, + FormConds: []FormCond{{Field: "Pinned", Op: "=", Value: "0"}}, + } + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"c"}) { + t.Errorf("Run().Pages = %v, want [c]", namesOf(got.Pages)) + } + if got.TotalPages != 2 { + t.Errorf("Run().TotalPages = %d, want 2", got.TotalPages) + } + if got.PageIndex != 1 { + t.Errorf("Run().PageIndex = %d, want 1", got.PageIndex) + } +} + +func TestRunAppliesTheLimitAfterFiltering(t *testing.T) { + src := noticeSource(t) + limit := 1 + q := Query{ + Page: 1, PerPage: 20, Limit: &limit, + FormConds: []FormCond{{Field: "Pinned", Op: "=", Value: "0"}}, + } + + got, err := Run(src, q, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !sameNames(got.Pages, []string{"b"}) { + t.Errorf("Run().Pages = %v, want [b]", namesOf(got.Pages)) + } + if got.Total != 1 { + t.Errorf("Run().Total = %d, want 1", got.Total) + } +} + +func TestParseReadsFormConditions(t *testing.T) { + q := parse(t, noticeSource(t), nil, nil, map[string]string{"category": "notice", "_pinnednotice": "1", "_rank": ">02"}) + want := []FormCond{{Field: "pinnednotice", Op: "=", Value: "1"}, {Field: "rank", Op: ">", Value: "02"}} + if len(q.FormConds) != len(want) { + t.Fatalf("FormConds = %+v, want %+v", q.FormConds, want) + } + for i := range want { + if q.FormConds[i] != want[i] { + t.Errorf("FormConds[%d] = %+v, want %+v", i, q.FormConds[i], want[i]) + } + } +} + +func TestParseReadsAFormSort(t *testing.T) { + q := parse(t, noticeSource(t), nil, nil, map[string]string{"category": "notice", "order": "_rank desc"}) + if q.FormSort != "rank" { + t.Errorf("FormSort = %q, want %q", q.FormSort, "rank") + } + if q.FormSortAsc { + t.Error("FormSortAsc = true, want false") + } + if q.Filter.Sort.Column != "created_at" { + t.Errorf("Filter.Sort.Column = %q, want %q", q.Filter.Sort.Column, "created_at") + } +} + +func TestParseLeavesAnOrdinarySortAlone(t *testing.T) { + q := parse(t, noticeSource(t), nil, nil, map[string]string{"category": "notice", "order": "name"}) + if q.FormSort != "" { + t.Errorf("FormSort = %q, want %q", q.FormSort, "") + } + if q.Filter.Sort.Column != "name" { + t.Errorf("Filter.Sort.Column = %q, want %q", q.Filter.Sort.Column, "name") + } +} diff --git a/internal/listpages/params.go b/internal/listpages/params.go new file mode 100644 index 00000000..4876e016 --- /dev/null +++ b/internal/listpages/params.go @@ -0,0 +1,733 @@ +// Package listpages answers [[module ListPages]]. +package listpages + +import ( + "errors" + "slices" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/form" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/wikinum" +) + +type Source interface { + TagIDsByName(categorySlug, name string) ([]int64, error) + ArticleTagIDs(articleID int64) ([]int64, error) + ArticleByRef(ref string) (*db.Article, error) + UserByUsername(name string) (*db.User, error) + UserByWikidotName(name string) (*db.User, error) + SiteRatingMode() (string, error) + CategoryRatingMode(category string) (string, error) + VoteStats(articleID int64) (db.VoteStats, error) + HiddenCategories(user *db.User) ([]string, error) + ListArticles(f db.ListFilter, offset int, limit *int) ([]db.Article, error) + CountArticles(f db.ListFilter, offset int, limit *int) (int, error) + LatestSource(articleID int64) (string, error) + CategoryForm(category string) (*form.Definition, error) +} + +type Query struct { + Invalid bool + + Only *db.Article + HasOnly bool + FullName string + HasFullName bool + + Filter db.ListFilter + HasSort bool + Offset int + Limit *int + Page int + PerPage int + + FormConds []FormCond + FormSort string + FormSortAsc bool +} + +// The value is compared as text, which is why a form meant to be ordered +// numerically pads its keys to a fixed width. +type FormCond struct { + Field string + Op string + Value string +} + +const defaultPerPage = 20 + +type parser struct { + src Source + article *db.Article + viewer *db.User + zone *time.Location + params map[string]string + path page.PathParams + out Query + err error +} + +func Parse(src Source, article *db.Article, viewer *db.User, zone *time.Location, params map[string]string, pathParams page.PathParams) (Query, error) { + if zone == nil { + zone = time.UTC + } + p := &parser{src: src, article: article, viewer: viewer, zone: zone, params: params, path: pathParams} + if p.params == nil { + p.params = map[string]string{} + } + p.out.Page = 1 + p.out.PerPage = defaultPerPage + + if p.parseSinglePage() { + return p.out, p.err + } + p.parseRange() + p.parseType() + p.parseName() + p.parseTags() + p.parseCategory() + p.parseParent() + p.parseLinkTo() + p.parseCreatedBy() + p.parseCreatedAt() + p.parseUpdatedAt() + p.parseRating() + p.parseVotes() + p.parsePopularity() + p.parseFormFields() + p.parseSort() + p.parseWindow() + if err := p.resolveRatingMode(); err != nil { + return p.out, err + } + return p.out, p.err +} + +func (p *parser) get(key string) string { return p.params[key] } + +func (p *parser) invalid() { p.out.Invalid = true } + +func (p *parser) fail(err error) { + if p.err == nil { + p.err = err + } +} + +func (p *parser) parseSinglePage() bool { + if p.get("name") == "." || p.get("range") == "." || p.get("fullname") == "." { + if p.article != nil { + p.out.Only, p.out.HasOnly = p.article, true + } else { + p.invalid() + } + return true + } + if full := p.get("fullname"); full != "" { + p.out.FullName, p.out.HasFullName = full, true + return true + } + return false +} + +func (p *parser) parseRange() { + if p.get("range") == "others" && p.article != nil { + p.out.Filter.NotID = &p.article.ID + } +} + +func (p *parser) parseType() { + pageType := p.get("pagetype") + if _, ok := p.params["pagetype"]; !ok { + pageType = db.PageTypeNormal + } + if pageType == db.PageTypeNormal || pageType == db.PageTypeHidden { + p.out.Filter.PageType = pageType + } +} + +func (p *parser) parseName() { + name, ok := p.params["name"] + if !ok { + return + } + if name == "*" { + return + } + name = strings.ToLower(strings.ReplaceAll(name, "%", "*")) + switch { + case name == "=": + if p.article == nil { + p.invalid() + return + } + p.out.Filter.Name, p.out.Filter.HasName = p.article.Name, true + case strings.Contains(name, "*"): + p.out.Filter.NamePrefix = name[:strings.Index(name, "*")] + p.out.Filter.HasNamePrefix = true + default: + p.out.Filter.Name, p.out.Filter.HasName = name, true + } +} + +func (p *parser) parseTags() { + raw, ok := p.params["tags"] + if !ok || raw == "*" { + return + } + raw = strings.ToLower(strings.ReplaceAll(raw, ",", " ")) + switch raw { + case "-": + p.out.Filter.NoTags = true + return + case "=", "==": + if p.article == nil { + p.invalid() + return + } + ids, err := p.src.ArticleTagIDs(p.article.ID) + if err != nil { + p.fail(err) + return + } + switch { + case raw == "=": + p.out.Filter.RequiredTags = ids + case len(ids) == 0: + p.out.Filter.NoTags = true + default: + p.out.Filter.ExactTags = ids + } + return + } + + var ( + requiredMissing bool + presentNamed int + presentFound int + ) + for _, tag := range splitFields(raw) { + switch { + case strings.HasPrefix(tag, "-"): + ids, err := p.tagIDs(tag[1:]) + if err != nil { + p.fail(err) + return + } + p.out.Filter.AbsentTags = append(p.out.Filter.AbsentTags, ids...) + case strings.HasPrefix(tag, "+"): + ids, err := p.tagIDs(tag[1:]) + if err != nil { + p.fail(err) + return + } + if len(ids) == 0 { + requiredMissing = true + } + p.out.Filter.RequiredTags = append(p.out.Filter.RequiredTags, ids...) + default: + ids, err := p.tagIDs(tag) + if err != nil { + p.fail(err) + return + } + presentNamed++ + presentFound += len(ids) + p.out.Filter.PresentTags = append(p.out.Filter.PresentTags, ids...) + } + } + // A tag nobody has ever used empties the listing only when it was required + // or when it was the only thing asked for. + if requiredMissing || (presentNamed > 0 && presentFound == 0) { + p.invalid() + } +} + +func (p *parser) tagIDs(name string) ([]int64, error) { + if strings.Contains(name, ":") { + category, bare := splitName(name) + return p.src.TagIDsByName(category, bare) + } + return p.src.TagIDsByName("", name) +} + +func splitName(fullName string) (category, name string) { + if before, after, found := strings.Cut(fullName, ":"); found { + return before, after + } + return db.DefaultCategory, fullName +} + +// The default is the page's own category, which is why a bare +// [[module ListPages]] lists siblings rather than the whole site. +func (p *parser) parseCategory() { + raw, ok := p.params["category"] + if !ok { + raw = "." + } + if raw == "*" { + return + } + raw = strings.ToLower(strings.ReplaceAll(raw, ",", " ")) + if raw == "." { + if p.article == nil { + p.invalid() + return + } + p.out.Filter.Categories = []string{p.article.Category} + return + } + for _, token := range strings.Split(raw, " ") { + token, _, _ = strings.Cut(token, ":") + if token == "" { + continue + } + if token == "." { + if p.article == nil { + continue + } + token = p.article.Category + } + if strings.HasPrefix(token, "-") { + p.out.Filter.NotCategories = append(p.out.Filter.NotCategories, token[1:]) + } else { + p.out.Filter.Categories = append(p.out.Filter.Categories, token) + } + } +} + +func (p *parser) parseParent() { + raw := p.get("parent") + if raw == "" { + return + } + f := &p.out.Filter + switch raw { + case "-": + f.HasParent = true + case "=": + f.HasParent, f.ParentID = true, p.articleParentID() + case "-=": + f.HasNotParent, f.NotParentID = true, p.articleParentID() + case ".": + if p.article == nil { + p.invalid() + return + } + f.HasParent, f.ParentID = true, &p.article.ID + default: + parent, err := p.src.ArticleByRef(strings.ToLower(raw)) + if errors.Is(err, db.ErrNotFound) { + p.invalid() + return + } + if err != nil { + p.fail(err) + return + } + f.HasParent, f.ParentID = true, &parent.ID + } +} + +func (p *parser) articleParentID() *int64 { + if p.article == nil { + return nil + } + return p.article.ParentID +} + +func (p *parser) parseCreatedBy() { + raw := p.get("created_by") + if raw == "" { + return + } + var ( + user *db.User + err error + ) + if raw == "." { + user = p.viewer + } else { + raw = strings.TrimSpace(raw) + if wd, ok := strings.CutPrefix(raw, "wd:"); ok { + user, err = p.src.UserByWikidotName(wd) + } else { + user, err = p.src.UserByUsername(raw) + } + if errors.Is(err, db.ErrNotFound) { + user, err = nil, nil + } + if err != nil { + p.fail(err) + return + } + } + if user == nil { + p.invalid() + return + } + p.out.Filter.AuthorID = &user.ID +} + +func (p *parser) parseLinkTo() { + raw := strings.TrimSpace(p.get("link_to")) + if raw == "" { + return + } + if raw == "." { + if p.article == nil { + p.invalid() + return + } + raw = p.article.FullName() + } + p.out.Filter.LinkTo, p.out.Filter.HasLinkTo = raw, true +} + +func (p *parser) parseCreatedAt() { + p.parseTime("created_at", func(a *db.Article) time.Time { return a.CreatedAt }, + func(f *db.TimeFilter) { p.out.Filter.CreatedAt = f }) +} + +func (p *parser) parseUpdatedAt() { + p.parseTime("updated_at", func(a *db.Article) time.Time { return a.UpdatedAt }, + func(f *db.TimeFilter) { p.out.Filter.UpdatedAt = f }) +} + +func (p *parser) parseTime(key string, of func(*db.Article) time.Time, set func(*db.TimeFilter)) { + raw := p.get(key) + if raw == "" { + return + } + if strings.TrimSpace(raw) == "=" { + if p.article == nil { + p.invalid() + return + } + y, m, d := of(p.article).In(p.zone).Date() + set(&db.TimeFilter{Op: db.TimeRange, + Start: time.Date(y, m, d, 0, 0, 0, 0, p.zone), End: time.Date(y, m, d+1, 0, 0, 0, 0, p.zone)}) + return + } + + op, rest := splitArgOperator(raw, []string{">=", "<=", "<>", ">", "<", "="}, "=") + start, end, ok := parseDateBounds(strings.TrimSpace(rest), p.zone) + if !ok { + p.invalid() + return + } + set(&db.TimeFilter{Op: timeOp(op), Start: start, End: end}) +} + +func timeOp(op string) string { + switch op { + case "<>": + return db.TimeExcludeRange + case "<": + return db.TimeLT + case ">": + return db.TimeGT + case "<=": + return db.TimeLTE + case ">=": + return db.TimeGTE + } + return db.TimeRange +} + +// The end is the first moment after the period rather than its last day, so a +// day covers all of its hours. Month and day are clamped rather than rejected, +// so 2020-13-99 is the last day of 2020-12. +func parseDateBounds(text string, zone *time.Location) (start, end time.Time, ok bool) { + parts := strings.Split(text, "-") + year, err := wikinum.Int(parts[0]) + if err != nil || year < 1 || year > 9999 { + return time.Time{}, time.Time{}, false + } + start = time.Date(year, 1, 1, 0, 0, 0, 0, zone) + end = time.Date(year+1, 1, 1, 0, 0, 0, 0, zone) + if len(parts) < 2 { + return start, end, true + } + + m, err := wikinum.Int(parts[1]) + if err != nil { + return time.Time{}, time.Time{}, false + } + month := time.Month(clamp(m, 1, 12)) + start = time.Date(year, month, 1, 0, 0, 0, 0, zone) + end = time.Date(year, month+1, 1, 0, 0, 0, 0, zone) + if len(parts) < 3 { + return start, end, true + } + + d, err := wikinum.Int(parts[2]) + if err != nil { + return time.Time{}, time.Time{}, false + } + day := clamp(d, 1, daysIn(year, int(month))) + start = time.Date(year, month, day, 0, 0, 0, 0, zone) + end = time.Date(year, month, day+1, 0, 0, 0, 0, zone) + return start, end, true +} + +func daysIn(year, month int) int { + return time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func (p *parser) parseRating() { + raw := p.get("rating") + if raw == "" { + return + } + if strings.TrimSpace(raw) == "=" { + rating, ok := p.currentRating() + if !ok { + return + } + p.out.Filter.Rating = &db.NumFilter{Op: db.NumEQ, Value: ratingValue(rating)} + return + } + op, rest := splitArgOperator(raw, []string{">=", "<=", "<>", ">", "<", "="}, "=") + value, err := wikinum.Float(strings.TrimSpace(rest)) + if err != nil { + p.invalid() + return + } + p.out.Filter.Rating = &db.NumFilter{Op: numOp(op), Value: value} +} + +func (p *parser) parseVotes() { + raw := p.get("votes") + if raw == "" { + return + } + if strings.TrimSpace(raw) == "=" { + rating, ok := p.currentRating() + if !ok { + return + } + p.out.Filter.Votes = &db.NumFilter{Op: db.NumEQ, Value: float64(rating.Votes)} + return + } + op, rest := splitArgOperator(raw, []string{">=", "<=", "<>", ">", "<", "="}, "=") + value, err := wikinum.Int(strings.TrimSpace(rest)) + if err != nil { + p.invalid() + return + } + p.out.Filter.Votes = &db.NumFilter{Op: numOp(op), Value: float64(value)} +} + +func (p *parser) parsePopularity() { + raw := p.get("popularity") + if raw == "" { + return + } + if strings.TrimSpace(raw) == "=" { + rating, ok := p.currentRating() + if !ok { + return + } + p.out.Filter.Popularity = &db.NumFilter{Op: db.NumEQ, Value: float64(rating.Popularity)} + return + } + op, rest := splitArgOperator(raw, []string{">=", "<=", "<>", ">", "<", "="}, "=") + value, err := wikinum.Int(strings.TrimSpace(rest)) + if err != nil { + p.invalid() + return + } + p.out.Filter.Popularity = &db.NumFilter{Op: numOp(op), Value: float64(value)} +} + +func (p *parser) currentRating() (page.Rating, bool) { + if p.article == nil { + p.invalid() + return page.Rating{}, false + } + mode, err := p.ratingModeOf(p.article.Category) + if err != nil { + p.fail(err) + return page.Rating{}, false + } + if mode == page.RatingModeDisabled { + return page.DisabledRating(), true + } + stats, err := p.src.VoteStats(p.article.ID) + if err != nil { + p.fail(err) + return page.Rating{}, false + } + return page.RatingOf(mode, stats), true +} + +func ratingValue(r page.Rating) float64 { + switch value := r.Value.(type) { + case int: + return float64(value) + case float64: + return value + } + return 0 +} + +// Splitting on a single space and stripping each piece keeps a tab inside a tag +// name rather than breaking on it. +func splitFields(s string) []string { + var out []string + for _, part := range strings.Split(s, " ") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} + +func numOp(op string) string { + switch op { + case "<>": + return db.NumNE + case "<": + return db.NumLT + case ">": + return db.NumGT + case "<=": + return db.NumLTE + case ">=": + return db.NumGTE + } + return db.NumEQ +} + +func (p *parser) parseFormFields() { + keys := make([]string, 0, len(p.params)) + for key := range p.params { + if len(key) > 1 && key[0] == '_' { + keys = append(keys, key) + } + } + slices.Sort(keys) + + for _, key := range keys { + op, rest := splitArgOperator(p.params[key], []string{">=", "<=", "<>", ">", "<", "="}, "=") + p.out.FormConds = append(p.out.FormConds, FormCond{ + Field: key[1:], + Op: op, + Value: strings.TrimSpace(rest), + }) + } +} + +func (p *parser) parseSort() { + raw, ok := p.params["order"] + if !ok { + raw = "created_at desc" + } + fields := strings.Split(raw, " ") + ascending := true + if len(fields) == 2 && fields[1] == "desc" { + ascending = false + } + column := fields[0] + if field, ok := strings.CutPrefix(column, "_"); ok && field != "" { + p.out.FormSort, p.out.FormSortAsc = field, ascending + // No column answers this one, so the database keeps its default order + // and the rows are put in the asked-for one afterwards. + column = "created_at" + ascending = false + } + p.out.Filter.Sort = db.Sort{Column: column, Ascending: ascending} + p.out.HasSort = true +} + +func (p *parser) parseWindow() { + if offset, err := wikinum.Int(p.getOr("offset", "0")); err == nil { + p.out.Offset = offset + } + if raw, ok := p.params["limit"]; ok { + if limit, err := wikinum.Int(raw); err == nil { + p.out.Limit = &limit + } + } + perPage, err := wikinum.Int(p.getOr("perpage", "20")) + if err != nil { + perPage = defaultPerPage + } + pageNum, err := wikinum.Int(p.pathOr("p", "1")) + if err != nil || pageNum < 1 { + pageNum = 1 + } + p.out.Page = pageNum + p.out.PerPage = perPage +} + +func (p *parser) getOr(key, def string) string { + if value, ok := p.params[key]; ok { + return value + } + return def +} + +func (p *parser) pathOr(key, def string) string { + if param, ok := p.path.Lookup(key); ok { + return param.Value + } + return def +} + +// The first named category decides, which is how one listing can rate its +// pages differently from the page it sits on. +func (p *parser) resolveRatingMode() error { + f := &p.out.Filter + needs := f.Rating != nil || f.Popularity != nil || + f.Sort.Column == db.SortRating || f.Sort.Column == db.SortPopularity + if !needs { + return nil + } + category := db.DefaultCategory + if len(f.Categories) > 0 && f.Categories[0] != "" { + category = f.Categories[0] + } + mode, err := p.ratingModeOf(category) + if err != nil { + return err + } + f.RatingMode = mode + return nil +} + +func (p *parser) ratingModeOf(category string) (string, error) { + site, err := p.src.SiteRatingMode() + if err != nil && !errors.Is(err, db.ErrNotFound) { + return "", err + } + own, err := p.src.CategoryRatingMode(category) + if err != nil && !errors.Is(err, db.ErrNotFound) { + return "", err + } + return page.RatingMode(site, own), nil +} + +// The prefixes are tried in the order given, so the two-character ones have +// to come first. +func splitArgOperator(arg string, allowed []string, def string) (op, rest string) { + for _, candidate := range allowed { + if strings.HasPrefix(arg, candidate) { + return candidate, arg[len(candidate):] + } + } + return def, arg +} diff --git a/internal/listpages/params_db_test.go b/internal/listpages/params_db_test.go new file mode 100644 index 00000000..84efda69 --- /dev/null +++ b/internal/listpages/params_db_test.go @@ -0,0 +1,347 @@ +package listpages + +import ( + "context" + "fmt" + "os" + "sort" + "strconv" + "strings" + "testing" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/form" + "github.com/WikitTeam/ProjectWikit/internal/page" +) + +const ( + paramsGolden = "testdata/params.golden" + paramsCorpus = "testdata/params_corpus.json" +) + +type paramCase struct { + Name string `json:"name"` + Page string `json:"page"` + Viewer string `json:"viewer"` + Params map[string]string `json:"params"` + Path map[string]string `json:"path"` +} + +func paramCases() []paramCase { + on := func(name string, params map[string]string) paramCase { + return paramCase{Name: name, Page: "main", Params: params} + } + return []paramCase{ + {Name: "no-page", Params: map[string]string{"category": "*"}}, + {Name: "no-page-dot-category", Params: map[string]string{}}, + on("bare", nil), + on("whole-site", map[string]string{"category": "*"}), + on("this-page", map[string]string{"name": "."}), + on("this-range", map[string]string{"range": "."}), + on("full-name", map[string]string{"fullname": "nav:side"}), + on("full-name-missing", map[string]string{"fullname": "no-such-page"}), + on("hidden-pages", map[string]string{"pagetype": "hidden", "category": "*"}), + on("bogus-pagetype", map[string]string{"pagetype": "sideways", "category": "*"}), + on("name-prefix", map[string]string{"name": "NAV%", "category": "*"}), + on("name-star-prefix", map[string]string{"name": "nav*", "category": "*"}), + on("name-exact", map[string]string{"name": "Main", "category": "*"}), + on("name-equals", map[string]string{"name": "=", "category": "*"}), + on("name-star", map[string]string{"name": "*", "category": "*"}), + on("category-list", map[string]string{"category": "forum, nav"}), + on("category-negated", map[string]string{"category": "* -forum"}), + on("category-with-colon", map[string]string{"category": "forum:thing"}), + on("category-dot-in-list", map[string]string{"category": ". forum"}), + on("tags-none", map[string]string{"tags": "-", "category": "*"}), + on("tags-own", map[string]string{"tags": "=", "category": "*"}), + on("tags-own-exact", map[string]string{"tags": "==", "category": "*"}), + on("tags-unknown", map[string]string{"tags": "nosuchtag", "category": "*"}), + on("tags-unknown-required", map[string]string{"tags": "+nosuchtag", "category": "*"}), + on("tags-unknown-absent", map[string]string{"tags": "-nosuchtag", "category": "*"}), + on("parent-none", map[string]string{"parent": "-", "category": "*"}), + on("parent-own", map[string]string{"parent": "=", "category": "*"}), + on("parent-not-own", map[string]string{"parent": "-=", "category": "*"}), + on("parent-self", map[string]string{"parent": ".", "category": "*"}), + on("parent-named", map[string]string{"parent": "NAV:side", "category": "*"}), + on("parent-missing", map[string]string{"parent": "no-such-page", "category": "*"}), + on("created-by-missing", map[string]string{"created_by": "nobody", "category": "*"}), + on("created-by-anonymous", map[string]string{"created_by": ".", "category": "*"}), + on("created-by-wikidot", map[string]string{"created_by": "wd:nobody", "category": "*"}), + on("created-at-year", map[string]string{"created_at": "2021", "category": "*"}), + on("created-at-month", map[string]string{"created_at": "2021-02", "category": "*"}), + on("created-at-leap", map[string]string{"created_at": "2020-02", "category": "*"}), + on("created-at-day", map[string]string{"created_at": "2021-02-09", "category": "*"}), + on("created-at-clamped", map[string]string{"created_at": "2021-13-99", "category": "*"}), + on("created-at-zeroes", map[string]string{"created_at": "2021-00-00", "category": "*"}), + on("created-at-gt", map[string]string{"created_at": ">2021", "category": "*"}), + on("created-at-gte", map[string]string{"created_at": ">=2021", "category": "*"}), + on("created-at-lt", map[string]string{"created_at": "<2021", "category": "*"}), + on("created-at-lte", map[string]string{"created_at": "<=2021", "category": "*"}), + on("created-at-outside", map[string]string{"created_at": "<>2021", "category": "*"}), + on("created-at-own-day", map[string]string{"created_at": "=", "category": "*"}), + on("created-at-junk", map[string]string{"created_at": "twenty", "category": "*"}), + on("created-at-trailing-dash", map[string]string{"created_at": "2021-", "category": "*"}), + on("created-at-negative", map[string]string{"created_at": "-5", "category": "*"}), + on("created-at-zero-year", map[string]string{"created_at": "0", "category": "*"}), + on("created-at-too-far", map[string]string{"created_at": "10000", "category": "*"}), + on("created-at-leading-space", map[string]string{"created_at": " >2021", "category": "*"}), + on("legacy-date", map[string]string{"date": "2021", "category": "*"}), + on("rating-int", map[string]string{"rating": "5", "category": "*"}), + on("rating-float", map[string]string{"rating": "3.5", "category": "*"}), + on("rating-negative", map[string]string{"rating": "-2", "category": "*"}), + on("rating-gte", map[string]string{"rating": ">=5", "category": "*"}), + on("rating-ne", map[string]string{"rating": "<>5", "category": "*"}), + on("rating-junk", map[string]string{"rating": "high", "category": "*"}), + on("rating-own", map[string]string{"rating": "=", "category": "*"}), + on("votes-int", map[string]string{"votes": "2", "category": "*"}), + on("votes-float", map[string]string{"votes": "2.5", "category": "*"}), + on("votes-own", map[string]string{"votes": "=", "category": "*"}), + on("popularity-gt", map[string]string{"popularity": ">50", "category": "*"}), + on("popularity-own", map[string]string{"popularity": "=", "category": "*"}), + on("order-name", map[string]string{"order": "name", "category": "*"}), + on("order-name-desc", map[string]string{"order": "name desc", "category": "*"}), + on("order-name-asc", map[string]string{"order": "name asc", "category": "*"}), + on("order-three-words", map[string]string{"order": "name desc extra", "category": "*"}), + on("order-unknown", map[string]string{"order": "nosuchcolumn", "category": "*"}), + on("order-empty", map[string]string{"order": "", "category": "*"}), + on("order-rating", map[string]string{"order": "rating", "category": "*"}), + on("window", map[string]string{"offset": "5", "limit": "40", "perpage": "300", "category": "*"}), + on("window-junk", map[string]string{"offset": "x", "limit": "y", "perpage": "z", "category": "*"}), + {Name: "window-page", Page: "main", Params: map[string]string{"category": "*"}, Path: map[string]string{"p": "3"}}, + {Name: "window-page-zero", Page: "main", Params: map[string]string{"category": "*"}, Path: map[string]string{"p": "0"}}, + {Name: "window-page-junk", Page: "main", Params: map[string]string{"category": "*"}, Path: map[string]string{"p": "x"}}, + } +} + +type dbSource struct { + ctx context.Context + d *db.DB + siteID int64 +} + +func (s dbSource) LatestSource(articleID int64) (string, error) { + return s.d.LatestSource(s.ctx, articleID) +} + +func (s dbSource) CategoryForm(category string) (*form.Definition, error) { + return nil, nil +} + +func (s dbSource) TagIDsByName(categorySlug, name string) ([]int64, error) { + return s.d.TagIDsByName(s.ctx, onlySiteID(s.ctx, s.d), categorySlug, name) +} + +func (s dbSource) ArticleTagIDs(articleID int64) ([]int64, error) { + return s.d.ArticleTagIDs(s.ctx, articleID) +} + +func (s dbSource) ArticleByRef(ref string) (*db.Article, error) { + return s.d.ArticleByName(s.ctx, onlySiteID(s.ctx, s.d), ref) +} + +func (s dbSource) UserByUsername(name string) (*db.User, error) { + return s.d.UserByUsername(s.ctx, name) +} + +func (s dbSource) UserByWikidotName(name string) (*db.User, error) { + return s.d.UserByWikidotName(s.ctx, name) +} + +func (s dbSource) SiteRatingMode() (string, error) { + return s.d.SiteRatingMode(s.ctx, s.siteID) +} + +func (s dbSource) CategoryRatingMode(category string) (string, error) { + return s.d.CategoryRatingMode(s.ctx, onlySiteID(s.ctx, s.d), category) +} + +func (s dbSource) VoteStats(articleID int64) (db.VoteStats, error) { + return s.d.VoteStats(s.ctx, articleID) +} + +func (s dbSource) HiddenCategories(*db.User) ([]string, error) { return nil, nil } + +func (s dbSource) ListArticles(f db.ListFilter, offset int, limit *int) ([]db.Article, error) { + return s.d.ListArticles(s.ctx, f, offset, limit) +} + +func (s dbSource) CountArticles(f db.ListFilter, offset int, limit *int) (int, error) { + return s.d.CountArticles(s.ctx, f, offset, limit) +} + +func testSource(t *testing.T) dbSource { + t.Helper() + dsn := os.Getenv(db.EnvDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the database test", db.EnvDSN) + } + ctx := context.Background() + d, err := db.Open(ctx, dsn) + if err != nil { + t.Fatalf("db.Open() err = %v, want nil", err) + } + t.Cleanup(d.Close) + site, err := d.SiteByHosts(ctx, []string{"localhost"}) + if err != nil { + t.Fatalf("SiteByHosts([localhost]) err = %v, want nil", err) + } + return dbSource{ctx: ctx, d: d, siteID: site.ID} +} + +// TestParseMatchesGolden records what every parameter resolves to against the +// live database. The row ids in it are the database's own, which is what lets +// the oracle print the same thing without a fixture of its own. +func TestParseMatchesGolden(t *testing.T) { + src := testSource(t) + cases := paramCases() + + var b strings.Builder + for _, c := range cases { + var host *db.Article + if c.Page != "" { + found, err := src.ArticleByRef(c.Page) + if err != nil { + t.Fatalf("ArticleByRef(%q) err = %v, want nil", c.Page, err) + } + host = found + } + var viewer *db.User + if c.Viewer != "" { + found, err := src.UserByUsername(c.Viewer) + if err != nil { + t.Fatalf("UserByUsername(%q) err = %v, want nil", c.Viewer, err) + } + viewer = found + } + q, err := Parse(src, host, viewer, nil, copyParams(c.Params), pathOf(c.Path)) + if err != nil { + t.Fatalf("Parse(%s) err = %v, want nil", c.Name, err) + } + fmt.Fprintf(&b, "=== %s\n%s", c.Name, dumpQuery(q)) + } + compareGolden(t, paramsGolden, b.String(), paramsCorpus, cases) +} + +func copyParams(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func pathOf(in map[string]string) page.PathParams { + keys := make([]string, 0, len(in)) + for k := range in { + keys = append(keys, k) + } + sort.Strings(keys) + out := make(page.PathParams, 0, len(keys)) + for _, k := range keys { + out = append(out, page.PathParam{Key: k, Value: in[k]}) + } + return out +} + +func dumpQuery(q Query) string { + f := q.Filter + var b strings.Builder + line := func(key, value string) { fmt.Fprintf(&b, "%s=%s\n", key, value) } + + line("invalid", strconv.FormatBool(q.Invalid)) + line("only", optionalID(q.HasOnly, articleID(q.Only))) + line("fullname", optional(q.HasFullName, q.FullName)) + line("pagetype", optional(f.PageType != "", f.PageType)) + line("name", optional(f.HasName, f.Name)) + line("nameprefix", optional(f.HasNamePrefix, f.NamePrefix)) + line("notags", strconv.FormatBool(f.NoTags)) + line("required", ids(f.RequiredTags)) + line("present", ids(f.PresentTags)) + line("absent", ids(f.AbsentTags)) + line("exact", ids(f.ExactTags)) + line("categories", strings.Join(f.Categories, ",")) + line("notcategories", strings.Join(f.NotCategories, ",")) + line("parent", pointerID(f.HasParent, f.ParentID)) + line("notparent", pointerID(f.HasNotParent, f.NotParentID)) + line("author", pointerID(f.AuthorID != nil, f.AuthorID)) + line("created_at", dumpTime(f.CreatedAt)) + line("rating", dumpNum(f.Rating)) + line("votes", dumpNum(f.Votes)) + line("popularity", dumpNum(f.Popularity)) + line("sort", optional(q.HasSort, f.Sort.Column+" "+direction(f.Sort.Ascending))) + line("offset", strconv.Itoa(q.Offset)) + line("limit", optionalInt(q.Limit)) + line("page", strconv.Itoa(q.Page)) + line("perpage", strconv.Itoa(q.PerPage)) + return b.String() +} + +func articleID(a *db.Article) int64 { + if a == nil { + return 0 + } + return a.ID +} + +func optional(present bool, value string) string { + if !present { + return "-" + } + return value +} + +func optionalID(present bool, id int64) string { + return optional(present, strconv.FormatInt(id, 10)) +} + +func optionalInt(v *int) string { + if v == nil { + return "-" + } + return strconv.Itoa(*v) +} + +func pointerID(present bool, id *int64) string { + if !present { + return "-" + } + if id == nil { + return "null" + } + return strconv.FormatInt(*id, 10) +} + +func ids(v []int64) string { + if len(v) == 0 { + return "" + } + sorted := append([]int64(nil), v...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + parts := make([]string, len(sorted)) + for i, id := range sorted { + parts[i] = strconv.FormatInt(id, 10) + } + return strings.Join(parts, ",") +} + +func dumpTime(f *db.TimeFilter) string { + if f == nil { + return "-" + } + stamp := func(t time.Time) string { return t.UTC().Format("2006-01-02T15:04:05") } + return f.Op + " " + stamp(f.Start) + " " + stamp(f.End) +} + +func dumpNum(f *db.NumFilter) string { + if f == nil { + return "-" + } + return f.Op + " " + strconv.FormatFloat(f.Value, 'f', 6, 64) +} + +func direction(ascending bool) string { + if ascending { + return "asc" + } + return "desc" +} diff --git a/internal/listpages/params_test.go b/internal/listpages/params_test.go new file mode 100644 index 00000000..52b6e2bf --- /dev/null +++ b/internal/listpages/params_test.go @@ -0,0 +1,687 @@ +package listpages + +import ( + "testing" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/form" + "github.com/WikitTeam/ProjectWikit/internal/page" +) + +func (f *fakeSource) LatestSource(articleID int64) (string, error) { + return f.sources[articleID], nil +} + +func (f *fakeSource) CategoryForm(category string) (*form.Definition, error) { + return f.forms[category], nil +} + +type fakeSource struct { + sources map[int64]string + forms map[string]*form.Definition + + tags map[string][]int64 + articleTags map[int64][]int64 + articles map[string]*db.Article + users map[string]*db.User + wikidotUsers map[string]*db.User + siteMode string + categoryMode map[string]string + votes db.VoteStats + hidden []string + + listed []db.Article + total int + + gotFilter db.ListFilter + gotOffset int + gotLimit *int + listCalls int +} + +func newFakeSource() *fakeSource { + return &fakeSource{ + tags: map[string][]int64{ + ":euclid": {1}, + "_default:.": nil, + "_default:hub": {2}, + "meta:hub": {2}, + ":hub": {2, 3}, + "scp:hub": {3}, + ":safe": {4}, + "_default:safe": {4}, + }, + articleTags: map[int64][]int64{7: {1, 4}}, + articles: map[string]*db.Article{ + "scp:scp-173": {ID: 7, Category: "scp", Name: "scp-173", Title: "173"}, + "main": {ID: 3, Category: db.DefaultCategory, Name: "main", Title: "Main"}, + }, + users: map[string]*db.User{"alice": {ID: 11, Username: "alice"}}, + wikidotUsers: map[string]*db.User{"Bob": {ID: 12, Username: "bob-1", WikidotUsername: "Bob"}}, + categoryMode: map[string]string{}, + } +} + +func (f *fakeSource) TagIDsByName(categorySlug, name string) ([]int64, error) { + return f.tags[categorySlug+":"+name], nil +} + +func (f *fakeSource) ArticleTagIDs(articleID int64) ([]int64, error) { + return f.articleTags[articleID], nil +} + +func (f *fakeSource) ArticleByRef(ref string) (*db.Article, error) { + if a, ok := f.articles[ref]; ok { + return a, nil + } + return nil, db.ErrNotFound +} + +func (f *fakeSource) UserByUsername(name string) (*db.User, error) { + if u, ok := f.users[name]; ok { + return u, nil + } + return nil, db.ErrNotFound +} + +func (f *fakeSource) UserByWikidotName(name string) (*db.User, error) { + if u, ok := f.wikidotUsers[name]; ok { + return u, nil + } + return nil, db.ErrNotFound +} + +func (f *fakeSource) SiteRatingMode() (string, error) { + if f.siteMode == "" { + return "", db.ErrNotFound + } + return f.siteMode, nil +} + +func (f *fakeSource) CategoryRatingMode(category string) (string, error) { + if mode, ok := f.categoryMode[category]; ok { + return mode, nil + } + return "", db.ErrNotFound +} + +func (f *fakeSource) VoteStats(int64) (db.VoteStats, error) { return f.votes, nil } + +func (f *fakeSource) HiddenCategories(*db.User) ([]string, error) { return f.hidden, nil } + +func (f *fakeSource) ListArticles(filter db.ListFilter, offset int, limit *int) ([]db.Article, error) { + f.gotFilter, f.gotOffset, f.gotLimit = filter, offset, limit + f.listCalls++ + return f.listed, nil +} + +func (f *fakeSource) CountArticles(db.ListFilter, int, *int) (int, error) { return f.total, nil } + +func article173() *db.Article { + parent := int64(3) + return &db.Article{ + ID: 7, Category: "scp", Name: "scp-173", Title: "173", ParentID: &parent, + CreatedAt: time.Date(2021, 6, 5, 14, 30, 0, 0, time.UTC), + } +} + +func parse(t *testing.T, src Source, a *db.Article, viewer *db.User, params map[string]string) Query { + t.Helper() + q, err := Parse(src, a, viewer, nil, params, nil) + if err != nil { + t.Fatalf("Parse(%v) err = %v, want nil", params, err) + } + return q +} + +func TestParseDefaultsToTheOwnCategoryAndNormalPages(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, nil) + + if q.Filter.PageType != db.PageTypeNormal { + t.Errorf("PageType = %q, want %q", q.Filter.PageType, db.PageTypeNormal) + } + if len(q.Filter.Categories) != 1 || q.Filter.Categories[0] != "scp" { + t.Errorf("Categories = %v, want [scp]", q.Filter.Categories) + } + if q.Filter.Sort != (db.Sort{Column: db.SortCreatedAt}) { + t.Errorf("Sort = %+v, want created_at desc", q.Filter.Sort) + } + if q.PerPage != 20 || q.Page != 1 { + t.Errorf("Page, PerPage = %d, %d, want 1, 20", q.Page, q.PerPage) + } +} + +func TestParseWithoutAnArticleHasNoCategory(t *testing.T) { + q := parse(t, newFakeSource(), nil, nil, nil) + if !q.Invalid { + t.Error("Parse(nil article).Invalid = false, want true") + } +} + +func TestParseNameDot(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"name": "."}) + if !q.HasOnly || q.Only.ID != 7 { + t.Errorf("Parse(name=.).Only = %+v, want article 7", q.Only) + } +} + +func TestParseRangeDotAndFullNameDotMeanTheSame(t *testing.T) { + for _, key := range []string{"range", "fullname"} { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{key: "."}) + if !q.HasOnly { + t.Errorf("Parse(%s=.).HasOnly = false, want true", key) + } + } +} + +func TestParseFullName(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"fullname": "main"}) + if !q.HasFullName || q.FullName != "main" { + t.Errorf("Parse(fullname=main).FullName = %q, want %q", q.FullName, "main") + } +} + +func TestParseNamePercentBecomesAPrefix(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"name": "SCP-%"}) + if !q.Filter.HasNamePrefix || q.Filter.NamePrefix != "scp-" { + t.Errorf("NamePrefix = %q, want %q", q.Filter.NamePrefix, "scp-") + } +} + +func TestParseNameEqualsTakesTheOwnName(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"name": "="}) + if !q.Filter.HasName || q.Filter.Name != "scp-173" { + t.Errorf("Name = %q, want %q", q.Filter.Name, "scp-173") + } +} + +func TestParseNameStarListsEverything(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"name": "*"}) + if q.Filter.HasName || q.Filter.HasNamePrefix { + t.Errorf("Name, NamePrefix set = %v, %v, want false, false", q.Filter.HasName, q.Filter.HasNamePrefix) + } +} + +func TestParseTagsSplitsBySign(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "+euclid,safe,-scp:hub"}) + if len(q.Filter.RequiredTags) != 1 || q.Filter.RequiredTags[0] != 1 { + t.Errorf("RequiredTags = %v, want [1]", q.Filter.RequiredTags) + } + if len(q.Filter.PresentTags) != 1 || q.Filter.PresentTags[0] != 4 { + t.Errorf("PresentTags = %v, want [4]", q.Filter.PresentTags) + } + if len(q.Filter.AbsentTags) != 1 || q.Filter.AbsentTags[0] != 3 { + t.Errorf("AbsentTags = %v, want [3]", q.Filter.AbsentTags) + } +} + +func TestParseTagsWithAnUnknownRequiredTag(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "+nosuchtag"}) + if !q.Invalid { + t.Error("Parse(tags=+nosuchtag).Invalid = false, want true") + } +} + +func TestParseTagsWithOnlyUnknownPresentTags(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "nosuchtag"}) + if !q.Invalid { + t.Error("Parse(tags=nosuchtag).Invalid = false, want true") + } +} + +func TestParseTagsKeepsGoingWhenOneOfSeveralIsUnknown(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "nosuchtag euclid"}) + if q.Invalid { + t.Error("Parse(tags=nosuchtag euclid).Invalid = true, want false") + } + if len(q.Filter.PresentTags) != 1 || q.Filter.PresentTags[0] != 1 { + t.Errorf("PresentTags = %v, want [1]", q.Filter.PresentTags) + } +} + +func TestParseTagsWithAnUnknownAbsentTag(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "-nosuchtag"}) + if q.Invalid { + t.Error("Parse(tags=-nosuchtag).Invalid = true, want false") + } + if len(q.Filter.AbsentTags) != 0 { + t.Errorf("AbsentTags = %v, want []", q.Filter.AbsentTags) + } +} + +func TestParseTagsDash(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "-"}) + if !q.Filter.NoTags { + t.Error("Parse(tags=-).NoTags = false, want true") + } +} + +func TestParseTagsEqualsTakesTheOwnTags(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "="}) + if len(q.Filter.RequiredTags) != 2 { + t.Errorf("RequiredTags = %v, want two entries", q.Filter.RequiredTags) + } + if len(q.Filter.ExactTags) != 0 { + t.Errorf("ExactTags = %v, want []", q.Filter.ExactTags) + } +} + +func TestParseTagsDoubleEqualsTakesTheOwnTags(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "=="}) + if len(q.Filter.ExactTags) != 2 { + t.Errorf("ExactTags = %v, want two entries", q.Filter.ExactTags) + } + if len(q.Filter.RequiredTags) != 0 { + t.Errorf("RequiredTags = %v, want []", q.Filter.RequiredTags) + } +} + +func TestParseRangeOthersLeavesOutTheOwnPage(t *testing.T) { + a := article173() + q := parse(t, newFakeSource(), a, nil, map[string]string{"range": "others"}) + if q.Filter.NotID == nil || *q.Filter.NotID != a.ID { + t.Errorf("Parse(range=others).NotID = %v, want %d", q.Filter.NotID, a.ID) + } + if q.HasOnly { + t.Error("Parse(range=others).HasOnly = true, want false") + } +} + +func TestParseRangeOthersWithoutAnArticle(t *testing.T) { + q := parse(t, newFakeSource(), nil, nil, map[string]string{"range": "others", "category": "*"}) + if q.Filter.NotID != nil { + t.Errorf("Parse(range=others, nil article).NotID = %d, want nil", *q.Filter.NotID) + } +} + +func TestParseTagsWithoutACategoryMatchesEveryCategory(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"tags": "hub"}) + if len(q.Filter.PresentTags) != 2 { + t.Errorf("PresentTags = %v, want two entries", q.Filter.PresentTags) + } +} + +func TestParseCategoryStarListsTheWholeSite(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"category": "*"}) + if len(q.Filter.Categories) != 0 || len(q.Filter.NotCategories) != 0 { + t.Errorf("Categories, NotCategories = %v, %v, want empty", q.Filter.Categories, q.Filter.NotCategories) + } +} + +func TestParseCategoryDropsWhatFollowsAColon(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"category": "scp:page -meta:page"}) + if len(q.Filter.Categories) != 1 || q.Filter.Categories[0] != "scp" { + t.Errorf("Categories = %v, want [scp]", q.Filter.Categories) + } + if len(q.Filter.NotCategories) != 1 || q.Filter.NotCategories[0] != "meta" { + t.Errorf("NotCategories = %v, want [meta]", q.Filter.NotCategories) + } +} + +func TestParseCategoryDotInAList(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"category": ". meta"}) + want := []string{"scp", "meta"} + if len(q.Filter.Categories) != 2 || q.Filter.Categories[0] != want[0] || q.Filter.Categories[1] != want[1] { + t.Errorf("Categories = %v, want %v", q.Filter.Categories, want) + } +} + +func TestParseParentDash(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"parent": "-"}) + if !q.Filter.HasParent || q.Filter.ParentID != nil { + t.Errorf("HasParent, ParentID = %v, %v, want true, nil", q.Filter.HasParent, q.Filter.ParentID) + } +} + +func TestParseParentEqualsTakesTheOwnParent(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"parent": "="}) + if q.Filter.ParentID == nil || *q.Filter.ParentID != 3 { + t.Errorf("ParentID = %v, want 3", q.Filter.ParentID) + } +} + +func TestParseParentDashEquals(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"parent": "-="}) + if !q.Filter.HasNotParent || q.Filter.NotParentID == nil || *q.Filter.NotParentID != 3 { + t.Errorf("NotParentID = %v, want 3", q.Filter.NotParentID) + } +} + +func TestParseParentDotIsThePageItself(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"parent": "."}) + if q.Filter.ParentID == nil || *q.Filter.ParentID != 7 { + t.Errorf("ParentID = %v, want 7", q.Filter.ParentID) + } +} + +func TestParseParentByName(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"parent": "MAIN"}) + if q.Filter.ParentID == nil || *q.Filter.ParentID != 3 { + t.Errorf("ParentID = %v, want 3", q.Filter.ParentID) + } +} + +func TestParseParentThatDoesNotExist(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"parent": "no-such-page"}) + if !q.Invalid { + t.Error("Parse(parent=no-such-page).Invalid = false, want true") + } +} + +func TestParseCreatedByDotWithoutAViewer(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"created_by": "."}) + if !q.Invalid { + t.Error("Parse(created_by=.).Invalid = false, want true") + } +} + +func TestParseCreatedByDotTakesTheViewer(t *testing.T) { + viewer := &db.User{ID: 99, Username: "carol"} + q := parse(t, newFakeSource(), article173(), viewer, map[string]string{"created_by": "."}) + if q.Filter.AuthorID == nil || *q.Filter.AuthorID != 99 { + t.Errorf("AuthorID = %v, want 99", q.Filter.AuthorID) + } +} + +func TestParseCreatedByWikidotPrefix(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"created_by": "wd:Bob"}) + if q.Filter.AuthorID == nil || *q.Filter.AuthorID != 12 { + t.Errorf("AuthorID = %v, want 12", q.Filter.AuthorID) + } +} + +func TestParseCreatedByUnknownUser(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"created_by": "nobody"}) + if !q.Invalid { + t.Error("Parse(created_by=nobody).Invalid = false, want true") + } +} + +func TestParseCreatedAtBounds(t *testing.T) { + cases := []struct { + in string + op string + start, end string + }{ + {"2021", db.TimeRange, "2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"2021-02", db.TimeRange, "2021-02-01T00:00:00Z", "2021-03-01T00:00:00Z"}, + {"2020-02", db.TimeRange, "2020-02-01T00:00:00Z", "2020-03-01T00:00:00Z"}, + {"2021-02-09", db.TimeRange, "2021-02-09T00:00:00Z", "2021-02-10T00:00:00Z"}, + {"2021-12", db.TimeRange, "2021-12-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"2021-12-31", db.TimeRange, "2021-12-31T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"2021-13-99", db.TimeRange, "2021-12-31T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"2021-00-00", db.TimeRange, "2021-01-01T00:00:00Z", "2021-01-02T00:00:00Z"}, + {">2021", db.TimeGT, "2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + {">=2021", db.TimeGTE, "2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"<2021", db.TimeLT, "2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"<=2021", db.TimeLTE, "2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + {"<>2021", db.TimeExcludeRange, "2021-01-01T00:00:00Z", "2022-01-01T00:00:00Z"}, + } + for _, c := range cases { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"created_at": c.in}) + got := q.Filter.CreatedAt + if got == nil { + t.Errorf("Parse(created_at=%q).CreatedAt = nil, want a filter", c.in) + continue + } + if got.Op != c.op { + t.Errorf("Parse(created_at=%q).Op = %q, want %q", c.in, got.Op, c.op) + } + if start := got.Start.Format(time.RFC3339); start != c.start { + t.Errorf("Parse(created_at=%q).Start = %q, want %q", c.in, start, c.start) + } + if end := got.End.Format(time.RFC3339); end != c.end { + t.Errorf("Parse(created_at=%q).End = %q, want %q", c.in, end, c.end) + } + } +} + +func TestParseCreatedAtEqualsIsTheOwnDay(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"created_at": "="}) + got := q.Filter.CreatedAt + if got == nil { + t.Fatal("Parse(created_at==).CreatedAt = nil, want a filter") + } + if start := got.Start.Format(time.RFC3339); start != "2021-06-05T00:00:00Z" { + t.Errorf("Start = %q, want %q", start, "2021-06-05T00:00:00Z") + } + if end := got.End.Format(time.RFC3339); end != "2021-06-06T00:00:00Z" { + t.Errorf("End = %q, want %q", end, "2021-06-06T00:00:00Z") + } +} + +func TestParseCreatedAtBoundsFollowTheSiteZone(t *testing.T) { + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + q, err := Parse(newFakeSource(), article173(), nil, shanghai, map[string]string{"created_at": "2021-06"}, nil) + if err != nil { + t.Fatalf("Parse(created_at=2021-06) err = %v, want nil", err) + } + got := q.Filter.CreatedAt + if got == nil { + t.Fatal("Parse(created_at=2021-06).CreatedAt = nil, want a filter") + } + if start := got.Start.UTC().Format(time.RFC3339); start != "2021-05-31T16:00:00Z" { + t.Errorf("Start = %q, want %q", start, "2021-05-31T16:00:00Z") + } +} + +func TestParseCreatedAtEqualsIsTheOwnDayInTheSiteZone(t *testing.T) { + late := article173() + late.CreatedAt = time.Date(2021, 6, 5, 20, 0, 0, 0, time.UTC) + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + q, err := Parse(newFakeSource(), late, nil, shanghai, map[string]string{"created_at": "="}, nil) + if err != nil { + t.Fatalf("Parse(created_at==) err = %v, want nil", err) + } + got := q.Filter.CreatedAt + if got == nil { + t.Fatal("Parse(created_at==).CreatedAt = nil, want a filter") + } + if start := got.Start.Format(time.RFC3339); start != "2021-06-06T00:00:00+08:00" { + t.Errorf("Start = %q, want %q", start, "2021-06-06T00:00:00+08:00") + } +} + +func TestParseLinkTo(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"link_to": "theme:black"}) + if !q.Filter.HasLinkTo || q.Filter.LinkTo != "theme:black" { + t.Errorf("LinkTo = %q, want %q", q.Filter.LinkTo, "theme:black") + } +} + +func TestParseLinkToDotIsTheOwnPage(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"link_to": "."}) + if q.Filter.LinkTo != "scp:scp-173" { + t.Errorf("LinkTo = %q, want %q", q.Filter.LinkTo, "scp:scp-173") + } +} + +func TestParseLinkToDotWithoutAnArticle(t *testing.T) { + q := parse(t, newFakeSource(), nil, nil, map[string]string{"link_to": "."}) + if q.Filter.HasLinkTo { + t.Errorf("Parse(link_to=., nil article).LinkTo = %q, want it unset", q.Filter.LinkTo) + } +} + +func TestParseUpdatedAtBounds(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"updated_at": ">=2021-02"}) + got := q.Filter.UpdatedAt + if got == nil { + t.Fatal("Parse(updated_at=>=2021-02).UpdatedAt = nil, want a filter") + } + if got.Op != db.TimeGTE { + t.Errorf("Op = %q, want %q", got.Op, db.TimeGTE) + } + if start := got.Start.Format(time.RFC3339); start != "2021-02-01T00:00:00Z" { + t.Errorf("Start = %q, want %q", start, "2021-02-01T00:00:00Z") + } +} + +func TestParseUpdatedAtDoesNotTouchCreatedAt(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"updated_at": "2021"}) + if q.Filter.CreatedAt != nil { + t.Errorf("CreatedAt = %+v, want nil", q.Filter.CreatedAt) + } +} + +func TestParseUpdatedAtEqualsIsTheOwnDay(t *testing.T) { + a := article173() + a.UpdatedAt = time.Date(2022, 3, 9, 8, 0, 0, 0, time.UTC) + q := parse(t, newFakeSource(), a, nil, map[string]string{"updated_at": "="}) + got := q.Filter.UpdatedAt + if got == nil { + t.Fatal("Parse(updated_at==).UpdatedAt = nil, want a filter") + } + if start := got.Start.Format(time.RFC3339); start != "2022-03-09T00:00:00Z" { + t.Errorf("Start = %q, want %q", start, "2022-03-09T00:00:00Z") + } + if end := got.End.Format(time.RFC3339); end != "2022-03-10T00:00:00Z" { + t.Errorf("End = %q, want %q", end, "2022-03-10T00:00:00Z") + } +} + +func TestParseOrderBySize(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"order": "size desc"}) + if want := (db.Sort{Column: db.SortSize}); q.Filter.Sort != want { + t.Errorf("Sort = %+v, want %+v", q.Filter.Sort, want) + } +} + +func TestParseCreatedAtThatDoesNotParse(t *testing.T) { + for _, in := range []string{"twenty", "2021-", "-5", "0", "10000", " >2021"} { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"created_at": in}) + if !q.Invalid { + t.Errorf("Parse(created_at=%q).Invalid = false, want true", in) + } + } +} + +func TestParseIgnoresTheLegacyDateSpelling(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"date": "2021"}) + if q.Filter.CreatedAt != nil { + t.Errorf("Parse(date=2021).CreatedAt = %+v, want nil", q.Filter.CreatedAt) + } +} + +func TestParseRatingOperators(t *testing.T) { + cases := []struct { + in string + op string + value float64 + }{ + {"5", db.NumEQ, 5}, + {"=5", db.NumEQ, 5}, + {">5", db.NumGT, 5}, + {">=5", db.NumGTE, 5}, + {"<5", db.NumLT, 5}, + {"<=5", db.NumLTE, 5}, + {"<>5", db.NumNE, 5}, + {"3.5", db.NumEQ, 3.5}, + {"-2", db.NumEQ, -2}, + } + for _, c := range cases { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"rating": c.in}) + got := q.Filter.Rating + if got == nil { + t.Errorf("Parse(rating=%q).Rating = nil, want a filter", c.in) + continue + } + if got.Op != c.op || got.Value != c.value { + t.Errorf("Parse(rating=%q) = %s %v, want %s %v", c.in, got.Op, got.Value, c.op, c.value) + } + } +} + +func TestParseVotesRejectsAFraction(t *testing.T) { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"votes": "3.5"}) + if !q.Invalid { + t.Error("Parse(votes=3.5).Invalid = false, want true") + } +} + +func TestParseRatingModeComesFromTheFirstCategory(t *testing.T) { + src := newFakeSource() + src.siteMode = page.RatingModeUpDown + src.categoryMode["meta"] = page.RatingModeStars + + q := parse(t, src, article173(), nil, map[string]string{"rating": ">1", "category": "meta scp"}) + if q.Filter.RatingMode != page.RatingModeStars { + t.Errorf("RatingMode = %q, want %q", q.Filter.RatingMode, page.RatingModeStars) + } +} + +func TestParseRatingModeIsLeftUnsetWhenNothingReadsIt(t *testing.T) { + src := newFakeSource() + src.siteMode = page.RatingModeStars + q := parse(t, src, article173(), nil, nil) + if q.Filter.RatingMode != "" { + t.Errorf("RatingMode = %q, want %q", q.Filter.RatingMode, "") + } +} + +func TestParseSort(t *testing.T) { + cases := []struct { + in string + column string + ascending bool + }{ + {"name", db.SortName, true}, + {"name desc", db.SortName, false}, + {"name asc", db.SortName, true}, + {"name desc extra", db.SortName, true}, + {"nosuchcolumn", "nosuchcolumn", true}, + {"", "", true}, + {"random", db.SortRandom, true}, + } + for _, c := range cases { + q := parse(t, newFakeSource(), article173(), nil, map[string]string{"order": c.in}) + if q.Filter.Sort.Column != c.column || q.Filter.Sort.Ascending != c.ascending { + t.Errorf("Parse(order=%q).Sort = %+v, want %s %v", c.in, q.Filter.Sort, c.column, c.ascending) + } + } +} + +func TestParseWindow(t *testing.T) { + src := newFakeSource() + q, err := Parse(src, article173(), nil, nil, + map[string]string{"offset": "5", "limit": "40", "perpage": "300"}, + page.PathParams{{Key: "p", Value: "3"}}) + if err != nil { + t.Fatalf("Parse() err = %v, want nil", err) + } + if q.Offset != 5 { + t.Errorf("Offset = %d, want 5", q.Offset) + } + if q.Limit == nil || *q.Limit != 40 { + t.Errorf("Limit = %v, want 40", q.Limit) + } + if q.PerPage != 300 { + t.Errorf("PerPage = %d, want 300", q.PerPage) + } + if q.Page != 3 { + t.Errorf("Page = %d, want 3", q.Page) + } +} + +func TestParseWindowFallsBackOnJunk(t *testing.T) { + src := newFakeSource() + q, err := Parse(src, article173(), nil, nil, + map[string]string{"offset": "x", "limit": "y", "perpage": "z"}, + page.PathParams{{Key: "p", Value: "0"}}) + if err != nil { + t.Fatalf("Parse() err = %v, want nil", err) + } + if q.Offset != 0 { + t.Errorf("Offset = %d, want 0", q.Offset) + } + if q.Limit != nil { + t.Errorf("Limit = %v, want nil", q.Limit) + } + if q.PerPage != 20 { + t.Errorf("PerPage = %d, want 20", q.PerPage) + } + if q.Page != 1 { + t.Errorf("Page = %d, want 1", q.Page) + } +} diff --git a/internal/listpages/query.go b/internal/listpages/query.go new file mode 100644 index 00000000..7f4d511d --- /dev/null +++ b/internal/listpages/query.go @@ -0,0 +1,122 @@ +package listpages + +import ( + "errors" + "slices" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/db" +) + +const maxPerPage = 250 + +// PageIndex is what the first row's %%index%% counts from, so it moves with +// the pagination rather than with the offset. +type Result struct { + Pages []db.Article + PageIndex int + Page int + TotalPages int + Total int +} + +func Run(src Source, q Query, viewer *db.User, paginate bool) (Result, error) { + // The cap belongs here rather than in the parsing, because what the reader + // asked for is what the frontend gets handed back. + q.PerPage = min(q.PerPage, maxPerPage) + empty := Result{Page: 1, TotalPages: 1} + if q.Invalid { + return empty, nil + } + hidden, err := src.HiddenCategories(viewer) + if err != nil { + return Result{}, err + } + + if q.HasOnly { + if containsFold(hidden, q.Only.Category) { + return empty, nil + } + return Result{Pages: []db.Article{*q.Only}, Page: 1, TotalPages: 1, Total: 1}, nil + } + if q.HasFullName { + found, err := src.ArticleByRef(strings.ToLower(q.FullName)) + if errors.Is(err, db.ErrNotFound) { + return empty, nil + } + if err != nil { + return Result{}, err + } + if containsFold(hidden, found.Category) { + return empty, nil + } + return Result{Pages: []db.Article{*found}, Page: 1, TotalPages: 1, Total: 1}, nil + } + + filter := q.Filter + filter.Hidden = hidden + + if q.hasFormWork() { + return runWithForm(src, q, filter, paginate) + } + + total, err := src.CountArticles(filter, q.Offset, q.Limit) + if err != nil { + return Result{}, err + } + + if !paginate { + pages, err := src.ListArticles(filter, q.Offset, q.Limit) + if err != nil { + return Result{}, err + } + return Result{Pages: pages, Page: 1, TotalPages: 1, Total: total}, nil + } + + offset, limit, skip := window(q) + var pages []db.Article + if !skip { + pages, err = src.ListArticles(filter, offset, limit) + if err != nil { + return Result{}, err + } + } + return Result{ + Pages: pages, + PageIndex: (q.Page - 1) * q.PerPage, + Page: q.Page, + TotalPages: totalPages(total, q.PerPage), + Total: total, + }, nil +} + +func window(q Query) (offset int, limit *int, skip bool) { + if q.PerPage <= 0 { + return 0, nil, true + } + start := (q.Page - 1) * q.PerPage + offset = q.Offset + start + perPage := q.PerPage + if q.Limit == nil { + return offset, &perPage, false + } + remaining := *q.Limit - start + if remaining <= 0 { + return offset, nil, true + } + bounded := min(perPage, remaining) + return offset, &bounded, false +} + +// A per-page of zero reports no pages at all rather than dividing by zero and +// failing the request. +func totalPages(total, perPage int) int { + if perPage <= 0 { + return 0 + } + return (total + perPage - 1) / perPage +} + +func containsFold(values []string, want string) bool { + return slices.ContainsFunc(values, func(v string) bool { return strings.EqualFold(v, want) }) +} diff --git a/internal/listpages/query_test.go b/internal/listpages/query_test.go new file mode 100644 index 00000000..c1f35563 --- /dev/null +++ b/internal/listpages/query_test.go @@ -0,0 +1,170 @@ +package listpages + +import ( + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/db" +) + +func intPtr(n int) *int { return &n } + +func TestWindowStacksPaginationOnTheOffset(t *testing.T) { + offset, limit, skip := window(Query{Offset: 5, Page: 3, PerPage: 20}) + if skip { + t.Fatal("window().skip = true, want false") + } + if offset != 45 { + t.Errorf("window().offset = %d, want 45", offset) + } + if limit == nil || *limit != 20 { + t.Errorf("window().limit = %v, want 20", limit) + } +} + +func TestWindowNarrowsTheLastPageToTheLimit(t *testing.T) { + offset, limit, skip := window(Query{Offset: 0, Limit: intPtr(25), Page: 2, PerPage: 20}) + if skip { + t.Fatal("window().skip = true, want false") + } + if offset != 20 { + t.Errorf("window().offset = %d, want 20", offset) + } + if limit == nil || *limit != 5 { + t.Errorf("window().limit = %v, want 5", limit) + } +} + +func TestWindowSkipsAPageBeyondTheLimit(t *testing.T) { + if _, _, skip := window(Query{Limit: intPtr(10), Page: 2, PerPage: 20}); !skip { + t.Error("window().skip = false, want true") + } +} + +func TestWindowSkipsWhenNoPageHasRoom(t *testing.T) { + if _, _, skip := window(Query{Page: 1, PerPage: 0}); !skip { + t.Error("window(perpage=0).skip = false, want true") + } +} + +func TestTotalPagesRoundsUp(t *testing.T) { + cases := []struct{ total, perPage, want int }{ + {0, 20, 0}, + {1, 20, 1}, + {20, 20, 1}, + {21, 20, 2}, + {40, 20, 2}, + {5, 0, 0}, + } + for _, c := range cases { + if got := totalPages(c.total, c.perPage); got != c.want { + t.Errorf("totalPages(%d, %d) = %d, want %d", c.total, c.perPage, got, c.want) + } + } +} + +func TestRunCapsThePageSize(t *testing.T) { + src := newFakeSource() + src.total = 1000 + got, err := Run(src, Query{Page: 1, PerPage: 300}, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if src.gotLimit == nil || *src.gotLimit != 250 { + t.Errorf("limit = %v, want 250", src.gotLimit) + } + if got.TotalPages != 4 { + t.Errorf("TotalPages = %d, want 4", got.TotalPages) + } +} + +func TestRunOfAnInvalidQueryListsNothing(t *testing.T) { + src := newFakeSource() + got, err := Run(src, Query{Invalid: true}, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if len(got.Pages) != 0 || got.Total != 0 || got.Page != 1 || got.TotalPages != 1 { + t.Errorf("Run(invalid) = %+v, want an empty first page", got) + } + if src.listCalls != 0 { + t.Errorf("listCalls = %d, want 0", src.listCalls) + } +} + +func TestRunOfASinglePage(t *testing.T) { + src := newFakeSource() + only := article173() + got, err := Run(src, Query{HasOnly: true, Only: only}, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if len(got.Pages) != 1 || got.Pages[0].ID != 7 || got.Total != 1 { + t.Errorf("Run(only) = %+v, want the one article", got) + } +} + +func TestRunOfASinglePageInAHiddenCategory(t *testing.T) { + src := newFakeSource() + src.hidden = []string{"SCP"} + got, err := Run(src, Query{HasOnly: true, Only: article173()}, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if len(got.Pages) != 0 { + t.Errorf("Run(only, hidden) = %+v, want nothing", got) + } +} + +func TestRunOfAFullNameThatDoesNotExist(t *testing.T) { + src := newFakeSource() + got, err := Run(src, Query{HasFullName: true, FullName: "no-such-page"}, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if len(got.Pages) != 0 || got.Total != 0 { + t.Errorf("Run(fullname) = %+v, want nothing", got) + } +} + +func TestRunPassesTheHiddenCategoriesToTheFilter(t *testing.T) { + src := newFakeSource() + src.hidden = []string{"admin"} + src.total = 3 + if _, err := Run(src, Query{Page: 1, PerPage: 20}, nil, true); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if len(src.gotFilter.Hidden) != 1 || src.gotFilter.Hidden[0] != "admin" { + t.Errorf("Hidden = %v, want [admin]", src.gotFilter.Hidden) + } +} + +func TestRunReportsThePageIndexOfTheFirstRow(t *testing.T) { + src := newFakeSource() + src.total = 45 + src.listed = []db.Article{*article173()} + got, err := Run(src, Query{Page: 3, PerPage: 20}, nil, true) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if got.PageIndex != 40 { + t.Errorf("PageIndex = %d, want 40", got.PageIndex) + } + if got.TotalPages != 3 { + t.Errorf("TotalPages = %d, want 3", got.TotalPages) + } +} + +func TestRunWithoutPaginationIgnoresThePageNumber(t *testing.T) { + src := newFakeSource() + src.total = 45 + got, err := Run(src, Query{Offset: 2, Page: 3, PerPage: 20}, nil, false) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if src.gotOffset != 2 { + t.Errorf("offset = %d, want 2", src.gotOffset) + } + if got.TotalPages != 1 || got.Page != 1 { + t.Errorf("Run(no pagination) page, total = %d, %d, want 1, 1", got.Page, got.TotalPages) + } +} diff --git a/internal/listpages/render.go b/internal/listpages/render.go new file mode 100644 index 00000000..cdb422b2 --- /dev/null +++ b/internal/listpages/render.go @@ -0,0 +1,209 @@ +package listpages + +import ( + "regexp" + "strconv" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/escape" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/page" +) + +type Sections struct { + Head string + Body string + Foot string +} + +var sectionPattern = regexp.MustCompile(`(?is)\A(?:.*\s*(\[\[head]]\n*(?P.*?)\n*\[\[/head]])|)` + + `(?:.*\s*(\[\[body]]\n*(?P.*?)\n*\[\[/body]])|)` + + `(?:.*\s*(\[\[foot]]\n*(?P.*?)\n*\[\[/foot]])|)`) + +func Split(content string) Sections { + match := sectionPattern.FindStringSubmatch(content) + if match == nil { + return Sections{} + } + var out Sections + for i, name := range sectionPattern.SubexpNames() { + switch name { + case "head": + out.Head = match[i] + case "body": + out.Body = match[i] + case "foot": + out.Foot = match[i] + } + } + return out +} + +const urlParamPrefix = "@url|" + +func URLParams(params map[string]string, path page.PathParams) (values map[string]string, null map[string]bool) { + null = map[string]bool{} + for key, value := range params { + if len(value) < len(urlParamPrefix) || + !strings.EqualFold(value[:len(urlParamPrefix)], urlParamPrefix) { + continue + } + if param, ok := path.Lookup(key); ok { + params[key] = param.Value + if param.Bare { + null[key] = true + } + continue + } + params[key] = value[len(urlParamPrefix):] + } + return params, null +} + +func BasePath(fullName string, path page.PathParams) string { + if fullName == "" { + return "#" + } + out := "/" + fullName + for _, param := range path { + if param.Key == "p" { + continue + } + out += "/" + quotePlus(param.Key) + "/" + quotePlus(pathValue(param)) + } + return out +} + +func pathValue(param page.PathParam) string { + if param.Bare { + return "None" + } + return param.Value +} + +func quotePlus(s string) string { + return strings.ReplaceAll(page.QuoteAll(s), "%20", "+") +} + +const ( + ind12 = " " + ind16 = " " + ind20 = " " + ind24 = " " +) + +func Pagination(loc *i18n.Localizer, basePath string, current, total int) string { + return pagination(loc, func(p int) string { return pageHref(basePath, p) }, true, current, total) +} + +// PaginationLinks pages a view reached by its own URL rather than swapped in +// place, so the numbers carry no hook for the script that re-renders a module. +func PaginationLinks(loc *i18n.Localizer, href func(page int) string, current, total int) string { + return pagination(loc, href, false, current, total) +} + +func pagination(loc *i18n.Localizer, href func(int) string, hook bool, current, total int) string { + if total <= 1 { + return "" + } + const around = 2 + + leftFrom := 1 + leftTo := leftFrom + 1 + if current < around*2+1 { + leftTo = around + 1 + } + if leftTo > total-1 { + leftTo = total - 1 + } + rightTo := total + rightFrom := max(leftTo+1, rightTo-1) + if current > rightTo-(around*2+1) { + rightFrom = max(leftTo+1, total-(around+1)) + } + centerFrom := max(leftTo+1, current-around) + centerTo := min(rightFrom-1, current+around) + + var b strings.Builder + b.WriteString(`
      ` + "\n" + ind16) + b.WriteString(`` + + text(loc, "module-listpages-pager-count", "page", strconv.Itoa(current), "total", strconv.Itoa(total)) + + `` + "\n" + ind16) + + if current > 1 { + b.WriteString("\n" + ind20 + step(loc, href, hook, current-1, "module-listpages-pager-prev") + "\n" + ind16) + } + b.WriteString("\n" + ind16) + + writeRange := func(class string, from, to int) { + for p := from; p <= to; p++ { + b.WriteString("\n" + ind20 + "\n" + ind24 + number(href, hook, class, p, p == current) + "\n" + ind20 + "\n" + ind16) + } + } + writeDots := func(show bool) { + if show { + b.WriteString("\n" + ind20 + `...` + "\n" + ind16) + } + b.WriteString("\n" + ind16) + } + + writeRange("1", leftFrom, leftTo) + b.WriteString("\n" + ind16) + writeDots(centerFrom > leftTo+1) + writeRange("2", centerFrom, centerTo) + b.WriteString("\n" + ind16) + writeDots(centerTo < rightFrom-1) + writeRange("3", rightFrom, rightTo) + b.WriteString("\n" + ind16) + + if current < total { + b.WriteString("\n" + ind20 + step(loc, href, hook, current+1, "module-listpages-pager-next") + "\n" + ind16) + } + b.WriteString("\n" + ind12 + "
      ") + return b.String() +} + +func step(loc *i18n.Localizer, href func(int) string, hook bool, target int, label string) string { + return `` + strconv.Itoa(p) + `` + } + return `` + "\n" + + ind16 + content + "\n" + + ind16 + pagination + "\n" + + ind16 + "" +} diff --git a/internal/listpages/render_test.go b/internal/listpages/render_test.go new file mode 100644 index 00000000..fb827fc8 --- /dev/null +++ b/internal/listpages/render_test.go @@ -0,0 +1,226 @@ +package listpages + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/wikijson" +) + +var update = flag.Bool("update", false, "rewrite the golden files and the corpora the oracles read") + +const ( + pagerGolden = "testdata/pagination.golden" + pagerCorpus = "testdata/pagination_corpus.json" + sectionsGolden = "testdata/sections.golden" + sectionsCorpus = "testdata/sections_corpus.json" +) + +type pagerCase struct { + Name string `json:"name"` + BasePath string `json:"base_path"` + Page int `json:"page"` + Total int `json:"total_pages"` +} + +func pagerCases() []pagerCase { + var out []pagerCase + add := func(name, basePath string, page, total int) { + out = append(out, pagerCase{Name: name, BasePath: basePath, Page: page, Total: total}) + } + add("single-page", "/main", 1, 1) + add("no-pages", "/main", 1, 0) + for _, total := range []int{2, 3, 4, 5, 6, 7, 9, 12, 30} { + for _, current := range []int{1, 2, 3, 4, 5, 6, total - 1, total} { + if current < 1 || current > total { + continue + } + add(fmt.Sprintf("total-%d-page-%d", total, current), "/main", current, total) + } + } + add("hash-base", "#", 3, 9) + add("base-with-params", "/scp:series/tag/euclid", 2, 4) + add("base-needing-escape", `/main/q/a"b&c`, 2, 4) + return out +} + +func localizer(t *testing.T) *i18n.Localizer { + t.Helper() + bundle, err := i18n.Load("") + if err != nil { + t.Fatalf("i18n.Load() err = %v, want nil", err) + } + return bundle.Localizer(i18n.DefaultLanguage) +} + +func TestPaginationMatchesGolden(t *testing.T) { + loc := localizer(t) + cases := pagerCases() + + var b strings.Builder + for _, c := range cases { + fmt.Fprintf(&b, "=== %s\n%s\n", c.Name, Pagination(loc, c.BasePath, c.Page, c.Total)) + } + compareGolden(t, pagerGolden, b.String(), pagerCorpus, cases) +} + +type sectionCase struct { + Name string `json:"name"` + Content string `json:"content"` +} + +func sectionCases() []sectionCase { + return []sectionCase{ + {"empty", ""}, + {"plain", "%%title%%"}, + {"head-only", "[[head]]\nbefore\n[[/head]]"}, + {"body-only", "[[body]]\n%%title%%\n[[/body]]"}, + {"foot-only", "[[foot]]\nafter\n[[/foot]]"}, + {"all-three", "[[head]]\nbefore\n[[/head]]\n[[body]]\n%%title%%\n[[/body]]\n[[foot]]\nafter\n[[/foot]]"}, + {"no-newline-after-open", "[[head]]before[[/head]]"}, + {"uppercase-tags", "[[HEAD]]\nbefore\n[[/HEAD]]"}, + {"multiline-body", "[[body]]\none\ntwo\nthree\n[[/body]]"}, + {"leading-text", "junk\n[[body]]\nrow\n[[/body]]"}, + {"empty-body", "[[body]]\n[[/body]]"}, + {"blank-line-after-open", "[[head]]\n\n||~ Title||\n[[/head]]\n[[body]]\n\n||%%title%%||\n\n[[/body]]"}, + {"table-rows", "[[head]]\n||~ Title||\n[[/head]]\n[[body]]\n||%%title%%||\n[[/body]]\n[[foot]]\n||~ End||\n[[/foot]]"}, + {"body-before-head", "[[body]]\nrow\n[[/body]]\n[[head]]\ntop\n[[/head]]"}, + } +} + +func TestSplitMatchesGolden(t *testing.T) { + cases := sectionCases() + + var b strings.Builder + for _, c := range cases { + s := Split(c.Content) + fmt.Fprintf(&b, "=== %s\nhead=%s\nbody=%s\nfoot=%s\n", c.Name, + wikijson.String(s.Head), wikijson.String(s.Body), wikijson.String(s.Foot)) + } + compareGolden(t, sectionsGolden, b.String(), sectionsCorpus, cases) +} + +func compareGolden(t *testing.T, goldenPath, got, corpusPath string, corpus any) { + t.Helper() + if *update { + writeJSON(t, corpusPath, corpus) + if err := os.WriteFile(filepath.FromSlash(goldenPath), []byte(got), 0o644); err != nil { + t.Fatalf("WriteFile(%s) err = %v, want nil", goldenPath, err) + } + return + } + want, err := os.ReadFile(filepath.FromSlash(goldenPath)) + if err != nil { + t.Fatalf("ReadFile(%s) err = %v, want nil", goldenPath, err) + } + if got != string(want) { + gotAt, wantAt := firstDiff(got, string(want)) + t.Errorf("render = %q, want %q", gotAt, wantAt) + } +} + +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("Marshal(%s) err = %v, want nil", path, err) + } + if err := os.WriteFile(filepath.FromSlash(path), append(data, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile(%s) err = %v, want nil", path, err) + } +} + +func firstDiff(got, want string) (string, string) { + for i := 0; i < len(got) && i < len(want); i++ { + if got[i] != want[i] { + return excerpt(got, i), excerpt(want, i) + } + } + at := min(len(got), len(want)) + return excerpt(got, at), excerpt(want, at) +} + +func excerpt(s string, at int) string { + return s[max(0, at-40):min(len(s), at+40)] +} + +func TestBasePathDropsThePageNumber(t *testing.T) { + params := page.PathParams{ + {Key: "tag", Value: "euclid"}, + {Key: "p", Value: "3"}, + {Key: "sort", Value: "a b"}, + } + got := BasePath("scp:series", params) + want := "/scp:series/tag/euclid/sort/a+b" + if got != want { + t.Errorf("BasePath(scp:series, ...) = %q, want %q", got, want) + } +} + +func TestBasePathSpellsABareKeyAsNone(t *testing.T) { + got := BasePath("main", page.PathParams{{Key: "edit", Bare: true}}) + if want := "/main/edit/None"; got != want { + t.Errorf("BasePath(main, edit) = %q, want %q", got, want) + } +} + +func TestBasePathWithoutAPage(t *testing.T) { + if got := BasePath("", nil); got != "#" { + t.Errorf("BasePath(\"\", nil) = %q, want %q", got, "#") + } +} + +func TestURLParamsReadsThePath(t *testing.T) { + params := map[string]string{"tags": "@url|default", "category": "scp"} + got, null := URLParams(params, page.PathParams{{Key: "tags", Value: "euclid"}}) + if got["tags"] != "euclid" { + t.Errorf("URLParams()[tags] = %q, want %q", got["tags"], "euclid") + } + if got["category"] != "scp" { + t.Errorf("URLParams()[category] = %q, want %q", got["category"], "scp") + } + if len(null) != 0 { + t.Errorf("len(null) = %d, want 0", len(null)) + } +} + +func TestURLParamsFallsBackToTheDefault(t *testing.T) { + got, _ := URLParams(map[string]string{"tags": "@url|euclid"}, nil) + if got["tags"] != "euclid" { + t.Errorf("URLParams()[tags] = %q, want %q", got["tags"], "euclid") + } +} + +func TestURLParamsMarksABareKeyAsNull(t *testing.T) { + _, null := URLParams(map[string]string{"tags": "@url|x"}, page.PathParams{{Key: "tags", Bare: true}}) + if !null["tags"] { + t.Errorf("null[tags] = false, want true") + } +} + +func TestURLParamsIgnoresCaseOfThePrefix(t *testing.T) { + got, _ := URLParams(map[string]string{"tags": "@URL|euclid"}, nil) + if got["tags"] != "euclid" { + t.Errorf("URLParams()[tags] = %q, want %q", got["tags"], "euclid") + } +} + +func TestWrapEscapesTheDataAttributes(t *testing.T) { + got := Wrap("body", "", `{"a": "b"}`, `{}`, `"x"`, "main") + if !strings.Contains(got, `data-list-pages-path-params="{"a": "b"}"`) { + t.Errorf("Wrap() = %q, want the path params escaped", got) + } + if !strings.HasPrefix(got, `
      ") { + t.Errorf("Wrap() = %q, want the box to close indented", got) + } +} diff --git a/internal/listpages/sitehelp_test.go b/internal/listpages/sitehelp_test.go new file mode 100644 index 00000000..b4abbede --- /dev/null +++ b/internal/listpages/sitehelp_test.go @@ -0,0 +1,22 @@ +package listpages + +import ( + "context" + + "github.com/WikitTeam/ProjectWikit/internal/db" +) + +func onlySiteID(ctx context.Context, d *db.DB) int64 { + slugs, err := d.SiteSlugs(ctx) + if err != nil { + panic(err) + } + if len(slugs) == 0 { + panic("the test database holds no site") + } + found, err := d.SiteBySlug(ctx, slugs[0]) + if err != nil { + panic(err) + } + return found.ID +} diff --git a/internal/listpages/testdata/pagination.golden b/internal/listpages/testdata/pagination.golden new file mode 100644 index 00000000..29c95496 --- /dev/null +++ b/internal/listpages/testdata/pagination.golden @@ -0,0 +1,2443 @@ +=== single-page + +=== no-pages + +=== total-2-page-1 + +=== total-2-page-2 +
      + 第 2 页; 共 2 页 + + « 上一页 + + + + 1 + + + + + + + + 2 + + + +
      +=== total-2-page-1 +
      + 第 1 页; 共 2 页 + + + + 1 + + + + + + + + 2 + + + + 下一页 » + +
      +=== total-2-page-2 +
      + 第 2 页; 共 2 页 + + « 上一页 + + + + 1 + + + + + + + + 2 + + + +
      +=== total-3-page-1 +
      + 第 1 页; 共 3 页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 下一页 » + +
      +=== total-3-page-2 +
      + 第 2 页; 共 3 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 下一页 » + +
      +=== total-3-page-3 +
      + 第 3 页; 共 3 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + +
      +=== total-3-page-2 +
      + 第 2 页; 共 3 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 下一页 » + +
      +=== total-3-page-3 +
      + 第 3 页; 共 3 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + +
      +=== total-4-page-1 +
      + 第 1 页; 共 4 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 下一页 » + +
      +=== total-4-page-2 +
      + 第 2 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 下一页 » + +
      +=== total-4-page-3 +
      + 第 3 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 下一页 » + +
      +=== total-4-page-4 +
      + 第 4 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + +
      +=== total-4-page-3 +
      + 第 3 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 下一页 » + +
      +=== total-4-page-4 +
      + 第 4 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + +
      +=== total-5-page-1 +
      + 第 1 页; 共 5 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 下一页 » + +
      +=== total-5-page-2 +
      + 第 2 页; 共 5 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 下一页 » + +
      +=== total-5-page-3 +
      + 第 3 页; 共 5 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 下一页 » + +
      +=== total-5-page-4 +
      + 第 4 页; 共 5 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 下一页 » + +
      +=== total-5-page-5 +
      + 第 5 页; 共 5 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 4 + + + + 5 + + + +
      +=== total-5-page-4 +
      + 第 4 页; 共 5 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 下一页 » + +
      +=== total-5-page-5 +
      + 第 5 页; 共 5 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 4 + + + + 5 + + + +
      +=== total-6-page-1 +
      + 第 1 页; 共 6 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + ... + + + + 5 + + + + 6 + + + + 下一页 » + +
      +=== total-6-page-2 +
      + 第 2 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 6 + + + + 下一页 » + +
      +=== total-6-page-3 +
      + 第 3 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 6 + + + + 下一页 » + +
      +=== total-6-page-4 +
      + 第 4 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 6 + + + + 下一页 » + +
      +=== total-6-page-5 +
      + 第 5 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 下一页 » + +
      +=== total-6-page-6 +
      + 第 6 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + +
      +=== total-6-page-5 +
      + 第 5 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 下一页 » + +
      +=== total-6-page-6 +
      + 第 6 页; 共 6 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + +
      +=== total-7-page-1 +
      + 第 1 页; 共 7 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + ... + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-2 +
      + 第 2 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + ... + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-3 +
      + 第 3 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-4 +
      + 第 4 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-5 +
      + 第 5 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + 3 + + + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-6 +
      + 第 6 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-6 +
      + 第 6 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 下一页 » + +
      +=== total-7-page-7 +
      + 第 7 页; 共 7 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + +
      +=== total-9-page-1 +
      + 第 1 页; 共 9 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + ... + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-2 +
      + 第 2 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + ... + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-3 +
      + 第 3 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + ... + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-4 +
      + 第 4 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + 6 + + + + ... + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-5 +
      + 第 5 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + 3 + + + + 4 + + + + 5 + + + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-6 +
      + 第 6 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + 4 + + + + 5 + + + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-8 +
      + 第 8 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== total-9-page-9 +
      + 第 9 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 6 + + + + 7 + + + + 8 + + + + 9 + + + +
      +=== total-12-page-1 +
      + 第 1 页; 共 12 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + ... + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-2 +
      + 第 2 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + ... + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-3 +
      + 第 3 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + ... + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-4 +
      + 第 4 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + 6 + + + + ... + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-5 +
      + 第 5 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + ... + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-6 +
      + 第 6 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + ... + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-11 +
      + 第 11 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 9 + + + + 10 + + + + 11 + + + + 12 + + + + 下一页 » + +
      +=== total-12-page-12 +
      + 第 12 页; 共 12 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 9 + + + + 10 + + + + 11 + + + + 12 + + + +
      +=== total-30-page-1 +
      + 第 1 页; 共 30 页 + + + + 1 + + + + 2 + + + + 3 + + + + + + ... + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-2 +
      + 第 2 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + ... + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-3 +
      + 第 3 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + ... + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-4 +
      + 第 4 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + 6 + + + + ... + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-5 +
      + 第 5 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + + + 3 + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + ... + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-6 +
      + 第 6 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + 4 + + + + 5 + + + + 6 + + + + 7 + + + + 8 + + + + ... + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-29 +
      + 第 29 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 27 + + + + 28 + + + + 29 + + + + 30 + + + + 下一页 » + +
      +=== total-30-page-30 +
      + 第 30 页; 共 30 页 + + « 上一页 + + + + 1 + + + + 2 + + + + ... + + + + + + 27 + + + + 28 + + + + 29 + + + + 30 + + + +
      +=== hash-base +
      + 第 3 页; 共 9 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + 4 + + + + 5 + + + + ... + + + + 8 + + + + 9 + + + + 下一页 » + +
      +=== base-with-params +
      + 第 2 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 下一页 » + +
      +=== base-needing-escape +
      + 第 2 页; 共 4 页 + + « 上一页 + + + + 1 + + + + 2 + + + + 3 + + + + + + + + 4 + + + + 下一页 » + +
      diff --git a/internal/listpages/testdata/pagination_corpus.json b/internal/listpages/testdata/pagination_corpus.json new file mode 100644 index 00000000..8f2f3aad --- /dev/null +++ b/internal/listpages/testdata/pagination_corpus.json @@ -0,0 +1,404 @@ +[ + { + "name": "single-page", + "base_path": "/main", + "page": 1, + "total_pages": 1 + }, + { + "name": "no-pages", + "base_path": "/main", + "page": 1, + "total_pages": 0 + }, + { + "name": "total-2-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 2 + }, + { + "name": "total-2-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 2 + }, + { + "name": "total-2-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 2 + }, + { + "name": "total-2-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 2 + }, + { + "name": "total-3-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 3 + }, + { + "name": "total-3-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 3 + }, + { + "name": "total-3-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 3 + }, + { + "name": "total-3-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 3 + }, + { + "name": "total-3-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 3 + }, + { + "name": "total-4-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 4 + }, + { + "name": "total-4-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 4 + }, + { + "name": "total-4-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 4 + }, + { + "name": "total-4-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 4 + }, + { + "name": "total-4-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 4 + }, + { + "name": "total-4-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 4 + }, + { + "name": "total-5-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 5 + }, + { + "name": "total-5-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 5 + }, + { + "name": "total-5-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 5 + }, + { + "name": "total-5-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 5 + }, + { + "name": "total-5-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 5 + }, + { + "name": "total-5-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 5 + }, + { + "name": "total-5-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 5 + }, + { + "name": "total-6-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 6 + }, + { + "name": "total-6-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 6 + }, + { + "name": "total-6-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 6 + }, + { + "name": "total-6-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 6 + }, + { + "name": "total-6-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 6 + }, + { + "name": "total-6-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 6 + }, + { + "name": "total-6-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 6 + }, + { + "name": "total-6-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 6 + }, + { + "name": "total-7-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 7 + }, + { + "name": "total-7-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 7 + }, + { + "name": "total-7-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 7 + }, + { + "name": "total-7-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 7 + }, + { + "name": "total-7-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 7 + }, + { + "name": "total-7-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 7 + }, + { + "name": "total-7-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 7 + }, + { + "name": "total-7-page-7", + "base_path": "/main", + "page": 7, + "total_pages": 7 + }, + { + "name": "total-9-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 9 + }, + { + "name": "total-9-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 9 + }, + { + "name": "total-9-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 9 + }, + { + "name": "total-9-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 9 + }, + { + "name": "total-9-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 9 + }, + { + "name": "total-9-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 9 + }, + { + "name": "total-9-page-8", + "base_path": "/main", + "page": 8, + "total_pages": 9 + }, + { + "name": "total-9-page-9", + "base_path": "/main", + "page": 9, + "total_pages": 9 + }, + { + "name": "total-12-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 12 + }, + { + "name": "total-12-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 12 + }, + { + "name": "total-12-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 12 + }, + { + "name": "total-12-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 12 + }, + { + "name": "total-12-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 12 + }, + { + "name": "total-12-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 12 + }, + { + "name": "total-12-page-11", + "base_path": "/main", + "page": 11, + "total_pages": 12 + }, + { + "name": "total-12-page-12", + "base_path": "/main", + "page": 12, + "total_pages": 12 + }, + { + "name": "total-30-page-1", + "base_path": "/main", + "page": 1, + "total_pages": 30 + }, + { + "name": "total-30-page-2", + "base_path": "/main", + "page": 2, + "total_pages": 30 + }, + { + "name": "total-30-page-3", + "base_path": "/main", + "page": 3, + "total_pages": 30 + }, + { + "name": "total-30-page-4", + "base_path": "/main", + "page": 4, + "total_pages": 30 + }, + { + "name": "total-30-page-5", + "base_path": "/main", + "page": 5, + "total_pages": 30 + }, + { + "name": "total-30-page-6", + "base_path": "/main", + "page": 6, + "total_pages": 30 + }, + { + "name": "total-30-page-29", + "base_path": "/main", + "page": 29, + "total_pages": 30 + }, + { + "name": "total-30-page-30", + "base_path": "/main", + "page": 30, + "total_pages": 30 + }, + { + "name": "hash-base", + "base_path": "#", + "page": 3, + "total_pages": 9 + }, + { + "name": "base-with-params", + "base_path": "/scp:series/tag/euclid", + "page": 2, + "total_pages": 4 + }, + { + "name": "base-needing-escape", + "base_path": "/main/q/a\"b\u0026c", + "page": 2, + "total_pages": 4 + } +] diff --git a/internal/listpages/testdata/params.golden b/internal/listpages/testdata/params.golden new file mode 100644 index 00000000..30aa6802 --- /dev/null +++ b/internal/listpages/testdata/params.golden @@ -0,0 +1,2002 @@ +=== no-page +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== no-page-dot-category +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== bare +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories=_default +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== whole-site +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== this-page +invalid=false +only=4 +fullname=- +pagetype=- +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=- +offset=0 +limit=- +page=1 +perpage=20 +=== this-range +invalid=false +only=4 +fullname=- +pagetype=- +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=- +offset=0 +limit=- +page=1 +perpage=20 +=== full-name +invalid=false +only=- +fullname=nav:side +pagetype=- +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=- +offset=0 +limit=- +page=1 +perpage=20 +=== full-name-missing +invalid=false +only=- +fullname=no-such-page +pagetype=- +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=- +offset=0 +limit=- +page=1 +perpage=20 +=== hidden-pages +invalid=false +only=- +fullname=- +pagetype=hidden +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== bogus-pagetype +invalid=false +only=- +fullname=- +pagetype=- +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== name-prefix +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=nav +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== name-star-prefix +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=nav +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== name-exact +invalid=false +only=- +fullname=- +pagetype=normal +name=main +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== name-equals +invalid=false +only=- +fullname=- +pagetype=normal +name=main +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== name-star +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== category-list +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories=forum,nav +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== category-negated +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories=* +notcategories=forum +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== category-with-colon +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories=forum +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== category-dot-in-list +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories=_default,forum +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== tags-none +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=true +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== tags-own +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== tags-own-exact +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=true +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== tags-unknown +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== tags-unknown-required +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== tags-unknown-absent +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== parent-none +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=null +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== parent-own +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=null +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== parent-not-own +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=null +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== parent-self +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=4 +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== parent-named +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=2 +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== parent-missing +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-by-missing +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-by-anonymous +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-by-wikidot +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-year +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2021-01-01T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-month +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2021-02-01T00:00:00 2021-03-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-leap +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2020-02-01T00:00:00 2020-03-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-day +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2021-02-09T00:00:00 2021-02-10T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-clamped +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2021-12-31T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-zeroes +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2021-01-01T00:00:00 2021-01-02T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-gt +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=gt 2021-01-01T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-gte +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=gte 2021-01-01T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-lt +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=lt 2021-01-01T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-lte +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=lte 2021-01-01T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-outside +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=exclude_range 2021-01-01T00:00:00 2022-01-01T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-own-day +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=range 2026-08-20T00:00:00 2026-08-21T00:00:00 +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-junk +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-trailing-dash +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-negative +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-zero-year +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-too-far +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== created-at-leading-space +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== legacy-date +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-int +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=eq 5.000000 +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-float +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=eq 3.500000 +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-negative +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=eq -2.000000 +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-gte +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=gte 5.000000 +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-ne +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=ne 5.000000 +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-junk +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== rating-own +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=eq 0.000000 +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== votes-int +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=eq 2.000000 +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== votes-float +invalid=true +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== votes-own +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=eq 0.000000 +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== popularity-gt +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=gt 50.000000 +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== popularity-own +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=eq 0.000000 +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== order-name +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=name asc +offset=0 +limit=- +page=1 +perpage=20 +=== order-name-desc +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=name desc +offset=0 +limit=- +page=1 +perpage=20 +=== order-name-asc +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=name asc +offset=0 +limit=- +page=1 +perpage=20 +=== order-three-words +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=name asc +offset=0 +limit=- +page=1 +perpage=20 +=== order-unknown +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=nosuchcolumn asc +offset=0 +limit=- +page=1 +perpage=20 +=== order-empty +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort= asc +offset=0 +limit=- +page=1 +perpage=20 +=== order-rating +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=rating asc +offset=0 +limit=- +page=1 +perpage=20 +=== window +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=5 +limit=40 +page=1 +perpage=300 +=== window-junk +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== window-page +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=3 +perpage=20 +=== window-page-zero +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 +=== window-page-junk +invalid=false +only=- +fullname=- +pagetype=normal +name=- +nameprefix=- +notags=false +required= +present= +absent= +exact= +categories= +notcategories= +parent=- +notparent=- +author=- +created_at=- +rating=- +votes=- +popularity=- +sort=created_at desc +offset=0 +limit=- +page=1 +perpage=20 diff --git a/internal/listpages/testdata/params_corpus.json b/internal/listpages/testdata/params_corpus.json new file mode 100644 index 00000000..99a28266 --- /dev/null +++ b/internal/listpages/testdata/params_corpus.json @@ -0,0 +1,763 @@ +[ + { + "name": "no-page", + "page": "", + "viewer": "", + "params": { + "category": "*" + }, + "path": null + }, + { + "name": "no-page-dot-category", + "page": "", + "viewer": "", + "params": {}, + "path": null + }, + { + "name": "bare", + "page": "main", + "viewer": "", + "params": null, + "path": null + }, + { + "name": "whole-site", + "page": "main", + "viewer": "", + "params": { + "category": "*" + }, + "path": null + }, + { + "name": "this-page", + "page": "main", + "viewer": "", + "params": { + "name": "." + }, + "path": null + }, + { + "name": "this-range", + "page": "main", + "viewer": "", + "params": { + "range": "." + }, + "path": null + }, + { + "name": "full-name", + "page": "main", + "viewer": "", + "params": { + "fullname": "nav:side" + }, + "path": null + }, + { + "name": "full-name-missing", + "page": "main", + "viewer": "", + "params": { + "fullname": "no-such-page" + }, + "path": null + }, + { + "name": "hidden-pages", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "pagetype": "hidden" + }, + "path": null + }, + { + "name": "bogus-pagetype", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "pagetype": "sideways" + }, + "path": null + }, + { + "name": "name-prefix", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "name": "NAV%" + }, + "path": null + }, + { + "name": "name-star-prefix", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "name": "nav*" + }, + "path": null + }, + { + "name": "name-exact", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "name": "Main" + }, + "path": null + }, + { + "name": "name-equals", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "name": "=" + }, + "path": null + }, + { + "name": "name-star", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "name": "*" + }, + "path": null + }, + { + "name": "category-list", + "page": "main", + "viewer": "", + "params": { + "category": "forum, nav" + }, + "path": null + }, + { + "name": "category-negated", + "page": "main", + "viewer": "", + "params": { + "category": "* -forum" + }, + "path": null + }, + { + "name": "category-with-colon", + "page": "main", + "viewer": "", + "params": { + "category": "forum:thing" + }, + "path": null + }, + { + "name": "category-dot-in-list", + "page": "main", + "viewer": "", + "params": { + "category": ". forum" + }, + "path": null + }, + { + "name": "tags-none", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "tags": "-" + }, + "path": null + }, + { + "name": "tags-own", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "tags": "=" + }, + "path": null + }, + { + "name": "tags-own-exact", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "tags": "==" + }, + "path": null + }, + { + "name": "tags-unknown", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "tags": "nosuchtag" + }, + "path": null + }, + { + "name": "tags-unknown-required", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "tags": "+nosuchtag" + }, + "path": null + }, + { + "name": "tags-unknown-absent", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "tags": "-nosuchtag" + }, + "path": null + }, + { + "name": "parent-none", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "parent": "-" + }, + "path": null + }, + { + "name": "parent-own", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "parent": "=" + }, + "path": null + }, + { + "name": "parent-not-own", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "parent": "-=" + }, + "path": null + }, + { + "name": "parent-self", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "parent": "." + }, + "path": null + }, + { + "name": "parent-named", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "parent": "NAV:side" + }, + "path": null + }, + { + "name": "parent-missing", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "parent": "no-such-page" + }, + "path": null + }, + { + "name": "created-by-missing", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_by": "nobody" + }, + "path": null + }, + { + "name": "created-by-anonymous", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_by": "." + }, + "path": null + }, + { + "name": "created-by-wikidot", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_by": "wd:nobody" + }, + "path": null + }, + { + "name": "created-at-year", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2021" + }, + "path": null + }, + { + "name": "created-at-month", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2021-02" + }, + "path": null + }, + { + "name": "created-at-leap", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2020-02" + }, + "path": null + }, + { + "name": "created-at-day", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2021-02-09" + }, + "path": null + }, + { + "name": "created-at-clamped", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2021-13-99" + }, + "path": null + }, + { + "name": "created-at-zeroes", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2021-00-00" + }, + "path": null + }, + { + "name": "created-at-gt", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "\u003e2021" + }, + "path": null + }, + { + "name": "created-at-gte", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "\u003e=2021" + }, + "path": null + }, + { + "name": "created-at-lt", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "\u003c2021" + }, + "path": null + }, + { + "name": "created-at-lte", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "\u003c=2021" + }, + "path": null + }, + { + "name": "created-at-outside", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "\u003c\u003e2021" + }, + "path": null + }, + { + "name": "created-at-own-day", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "=" + }, + "path": null + }, + { + "name": "created-at-junk", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "twenty" + }, + "path": null + }, + { + "name": "created-at-trailing-dash", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "2021-" + }, + "path": null + }, + { + "name": "created-at-negative", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "-5" + }, + "path": null + }, + { + "name": "created-at-zero-year", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "0" + }, + "path": null + }, + { + "name": "created-at-too-far", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": "10000" + }, + "path": null + }, + { + "name": "created-at-leading-space", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "created_at": " \u003e2021" + }, + "path": null + }, + { + "name": "legacy-date", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "date": "2021" + }, + "path": null + }, + { + "name": "rating-int", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "5" + }, + "path": null + }, + { + "name": "rating-float", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "3.5" + }, + "path": null + }, + { + "name": "rating-negative", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "-2" + }, + "path": null + }, + { + "name": "rating-gte", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "\u003e=5" + }, + "path": null + }, + { + "name": "rating-ne", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "\u003c\u003e5" + }, + "path": null + }, + { + "name": "rating-junk", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "high" + }, + "path": null + }, + { + "name": "rating-own", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "rating": "=" + }, + "path": null + }, + { + "name": "votes-int", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "votes": "2" + }, + "path": null + }, + { + "name": "votes-float", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "votes": "2.5" + }, + "path": null + }, + { + "name": "votes-own", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "votes": "=" + }, + "path": null + }, + { + "name": "popularity-gt", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "popularity": "\u003e50" + }, + "path": null + }, + { + "name": "popularity-own", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "popularity": "=" + }, + "path": null + }, + { + "name": "order-name", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "name" + }, + "path": null + }, + { + "name": "order-name-desc", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "name desc" + }, + "path": null + }, + { + "name": "order-name-asc", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "name asc" + }, + "path": null + }, + { + "name": "order-three-words", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "name desc extra" + }, + "path": null + }, + { + "name": "order-unknown", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "nosuchcolumn" + }, + "path": null + }, + { + "name": "order-empty", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "" + }, + "path": null + }, + { + "name": "order-rating", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "order": "rating" + }, + "path": null + }, + { + "name": "window", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "limit": "40", + "offset": "5", + "perpage": "300" + }, + "path": null + }, + { + "name": "window-junk", + "page": "main", + "viewer": "", + "params": { + "category": "*", + "limit": "y", + "offset": "x", + "perpage": "z" + }, + "path": null + }, + { + "name": "window-page", + "page": "main", + "viewer": "", + "params": { + "category": "*" + }, + "path": { + "p": "3" + } + }, + { + "name": "window-page-zero", + "page": "main", + "viewer": "", + "params": { + "category": "*" + }, + "path": { + "p": "0" + } + }, + { + "name": "window-page-junk", + "page": "main", + "viewer": "", + "params": { + "category": "*" + }, + "path": { + "p": "x" + } + } +] diff --git a/internal/listpages/testdata/sections.golden b/internal/listpages/testdata/sections.golden new file mode 100644 index 00000000..6d8f2b8d --- /dev/null +++ b/internal/listpages/testdata/sections.golden @@ -0,0 +1,56 @@ +=== empty +head="" +body="" +foot="" +=== plain +head="" +body="" +foot="" +=== head-only +head="before" +body="" +foot="" +=== body-only +head="" +body="%%title%%" +foot="" +=== foot-only +head="" +body="" +foot="after" +=== all-three +head="before" +body="%%title%%" +foot="after" +=== no-newline-after-open +head="before" +body="" +foot="" +=== uppercase-tags +head="before" +body="" +foot="" +=== multiline-body +head="" +body="one\ntwo\nthree" +foot="" +=== leading-text +head="" +body="row" +foot="" +=== empty-body +head="" +body="" +foot="" +=== blank-line-after-open +head="||~ Title||" +body="||%%title%%||" +foot="" +=== table-rows +head="||~ Title||" +body="||%%title%%||" +foot="||~ End||" +=== body-before-head +head="top" +body="" +foot="" diff --git a/internal/listpages/testdata/sections_corpus.json b/internal/listpages/testdata/sections_corpus.json new file mode 100644 index 00000000..4e756e55 --- /dev/null +++ b/internal/listpages/testdata/sections_corpus.json @@ -0,0 +1,58 @@ +[ + { + "name": "empty", + "content": "" + }, + { + "name": "plain", + "content": "%%title%%" + }, + { + "name": "head-only", + "content": "[[head]]\nbefore\n[[/head]]" + }, + { + "name": "body-only", + "content": "[[body]]\n%%title%%\n[[/body]]" + }, + { + "name": "foot-only", + "content": "[[foot]]\nafter\n[[/foot]]" + }, + { + "name": "all-three", + "content": "[[head]]\nbefore\n[[/head]]\n[[body]]\n%%title%%\n[[/body]]\n[[foot]]\nafter\n[[/foot]]" + }, + { + "name": "no-newline-after-open", + "content": "[[head]]before[[/head]]" + }, + { + "name": "uppercase-tags", + "content": "[[HEAD]]\nbefore\n[[/HEAD]]" + }, + { + "name": "multiline-body", + "content": "[[body]]\none\ntwo\nthree\n[[/body]]" + }, + { + "name": "leading-text", + "content": "junk\n[[body]]\nrow\n[[/body]]" + }, + { + "name": "empty-body", + "content": "[[body]]\n[[/body]]" + }, + { + "name": "blank-line-after-open", + "content": "[[head]]\n\n||~ Title||\n[[/head]]\n[[body]]\n\n||%%title%%||\n\n[[/body]]" + }, + { + "name": "table-rows", + "content": "[[head]]\n||~ Title||\n[[/head]]\n[[body]]\n||%%title%%||\n[[/body]]\n[[foot]]\n||~ End||\n[[/foot]]" + }, + { + "name": "body-before-head", + "content": "[[body]]\nrow\n[[/body]]\n[[head]]\ntop\n[[/head]]" + } +] diff --git a/internal/localitem/code.go b/internal/localitem/code.go new file mode 100644 index 00000000..12d6d2ea --- /dev/null +++ b/internal/localitem/code.go @@ -0,0 +1,54 @@ +package localitem + +import ( + "strconv" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/renderer" +) + +func codeMime(language string) string { + switch strings.ToLower(language) { + case "html", "xhtml": + return "text/html; charset=utf-8" + case "javascript", "js", "jsx": + return "text/javascript; charset=utf-8" + case "xml": + return "application/xml; charset=utf-8" + case "css": + return "text/css; charset=utf-8" + } + return "text/plain; charset=utf-8" +} + +func (h *handler) code(req *request, rest string) (item, error) { + index, err := strconv.Atoi(rest) + if err != nil { + return missing(noCode), nil + } + + parts, err := h.parts(req, renderer.ModeSystem) + if err != nil { + return item{}, err + } + + index-- + if index < 0 || index >= len(parts.Code) { + return missing(noCode), nil + } + block := parts.Code[index] + return found(codeMime(block.Language), block.Source), nil +} + +// Page variables belong to a rendered page, and a code block hands back what +// its author typed, so the source goes in as it was written. +func (h *handler) parts(req *request, mode renderer.Mode) (renderer.Parts, error) { + info, err := req.env.PageInfo(req.article) + if err != nil { + return renderer.Parts{}, err + } + vars := req.env.Vars(req.article) + pc := page.NewContext(req.article, req.article, req.params, req.user) + return req.env.CodeAndHTML(req.source, info, req.env.Callbacks(vars, pc), mode) +} diff --git a/internal/localitem/html.go b/internal/localitem/html.go new file mode 100644 index 00000000..cc5aa7b0 --- /dev/null +++ b/internal/localitem/html.go @@ -0,0 +1,52 @@ +package localitem + +import ( + "crypto/md5" + "encoding/hex" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/renderer" +) + +func (h *handler) html(req *request, rest string) (item, error) { + hash, id, ok := strings.Cut(rest, "-") + if !ok { + return missing(noHTML), nil + } + + info, err := req.env.PageInfo(req.article) + if err != nil { + return item{}, err + } + vars := req.env.Vars(req.article) + pc := page.NewContext(req.article, req.article, req.params, req.user) + cb := req.env.Callbacks(vars, pc) + + // A block can live on an included page. The text render is the pass that + // pulls those in, so the blocks are collected off it. + result, err := req.env.Text(req.source, info, cb, renderer.ModeSystem) + if err != nil { + return item{}, err + } + + block, ok := blockByHash(result.HTML, hash) + if !ok { + return missing(noHTML), nil + } + prepend, err := cb.GetHTMLInjectedCode(id) + if err != nil { + return item{}, err + } + return found(htmlMime, prepend+block), nil +} + +func blockByHash(blocks []string, hash string) (string, bool) { + for _, block := range blocks { + sum := md5.Sum([]byte(block)) + if hex.EncodeToString(sum[:]) == hash { + return block, true + } + } + return "", false +} diff --git a/internal/localitem/localitem.go b/internal/localitem/localitem.go new file mode 100644 index 00000000..d9d7d67b --- /dev/null +++ b/internal/localitem/localitem.go @@ -0,0 +1,269 @@ +// Package localitem answers the three URLs that hand out one piece of a page's +// own source rather than a file. +package localitem + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/auth" + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/i18n" + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/pagerender" + "github.com/WikitTeam/ProjectWikit/internal/perms" + "github.com/WikitTeam/ProjectWikit/internal/renderer" + "github.com/WikitTeam/ProjectWikit/internal/repo" + "github.com/WikitTeam/ProjectWikit/internal/roles" + "github.com/WikitTeam/ProjectWikit/internal/site" +) + +const ( + CodePrefix = "/local--code/" + HTMLPrefix = "/local--html/" + ThemePrefix = "/local--theme/" +) + +const ( + htmlMime = "text/html; charset=utf-8" + cssMime = "text/css; charset=utf-8" + textMime = "text/plain; charset=utf-8" + + allowedMethods = "GET, HEAD, OPTIONS" + + noArticle = "Article not found" + noPerm = "Permission denied" + noCode = "Code block not found" + noHTML = "HTML block not found" + noResource = "Not found" +) + +type Deps struct { + DB *db.DB + Engine renderer.Renderer + Bundle *i18n.Bundle + Icons roles.IconLoader + Log *slog.Logger + + Now func() time.Time +} + +type handler struct { + deps Deps + prefix string + answer func(req *request, rest string) (item, error) +} + +var _ http.Handler = (*handler)(nil) + +func NewCode(d Deps) http.Handler { + h := &handler{deps: withDefaults(d), prefix: CodePrefix} + h.answer = h.code + return h +} + +func NewHTML(d Deps) http.Handler { + h := &handler{deps: withDefaults(d), prefix: HTMLPrefix} + h.answer = h.html + return h +} + +func NewTheme(d Deps) http.Handler { + h := &handler{deps: withDefaults(d), prefix: ThemePrefix} + h.answer = h.theme + return h +} + +func withDefaults(d Deps) Deps { + if d.Now == nil { + d.Now = time.Now + } + return d +} + +func (h *handler) log() *slog.Logger { + if h.deps.Log == nil { + return slog.Default() + } + return h.deps.Log +} + +func (h *handler) now() time.Time { return h.deps.Now() } + +type item struct { + status int + contentType string + body string +} + +func found(contentType, body string) item { + return item{status: http.StatusOK, contentType: contentType, body: body} +} + +func missing(body string) item { + return item{status: http.StatusNotFound, contentType: htmlMime, body: body} +} + +type request struct { + env *pagerender.Env + article *db.Article + user *db.User + source string + params page.PathParams + query url.Values +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Allow", allowedMethods) + w.Header().Set("Content-Type", htmlMime) + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", allowedMethods) + write(w, r, item{status: http.StatusMethodNotAllowed, contentType: textMime, + body: http.StatusText(http.StatusMethodNotAllowed)}) + return + } + + name, rest, ok := split(r.URL.Path, h.prefix) + if !ok { + write(w, r, missing(noResource)) + return + } + + req, out, err := h.load(r, name) + if err != nil { + h.serverError(w, r, err) + return + } + if req == nil { + write(w, r, out) + return + } + + out, err = h.answer(req, rest) + if err != nil { + h.serverError(w, r, err) + return + } + write(w, r, out) +} + +func (h *handler) serverError(w http.ResponseWriter, r *http.Request, err error) { + h.log().Error("render local item", "path", r.URL.Path, "err", err) + write(w, r, item{status: http.StatusInternalServerError, contentType: textMime, + body: http.StatusText(http.StatusInternalServerError)}) +} + +func write(w http.ResponseWriter, r *http.Request, out item) { + w.Header().Set("Content-Type", out.contentType) + w.Header().Set("Content-Length", strconv.Itoa(len(out.body))) + w.WriteHeader(out.status) + if r.Method == http.MethodHead { + return + } + if _, err := io.WriteString(w, out.body); err != nil && !errors.Is(err, http.ErrBodyNotAllowed) { + return + } +} + +func split(path, prefix string) (name, rest string, ok bool) { + tail, ok := strings.CutPrefix(path, prefix) + if !ok { + return "", "", false + } + name, rest, ok = strings.Cut(tail, "/") + if !ok || name == "" || rest == "" || strings.Contains(rest, "/") { + return "", "", false + } + return name, rest, true +} + +func (h *handler) load(r *http.Request, name string) (*request, item, error) { + ctx := r.Context() + current := site.FromContext(ctx) + if current == nil { + return nil, item{}, errors.New("localitem: the request carries no site") + } + user := auth.FromContext(ctx) + + article, err := h.deps.DB.ArticleByName(ctx, current.ID, name) + if errors.Is(err, db.ErrNotFound) { + return nil, missing(noArticle), nil + } + if err != nil { + return nil, item{}, err + } + + perm := repo.NewPerms(ctx, h.deps.DB) + subject, err := perm.Subject(user, h.now()) + if err != nil { + return nil, item{}, err + } + object, err := perm.Article(article, user) + if err != nil { + return nil, item{}, err + } + if !perms.Resolve(subject, object).Has(perms.ViewArticles) { + return nil, item{status: http.StatusForbidden, contentType: htmlMime, body: noPerm}, nil + } + + query := r.URL.Query() + source, err := h.source(ctx, article, query.Get("revNum")) + if err != nil { + return nil, item{}, err + } + + loc := h.deps.Bundle.For(ctx) + env := pagerender.Deps{DB: h.deps.DB, Engine: h.deps.Engine, Icons: h.deps.Icons}. + Env(ctx, loc, current, user) + + return &request{ + env: env, + article: article, + user: user, + source: source, + params: page.ParsePathParams(query.Get("pathParams")), + query: query, + }, item{}, nil +} + +// A revision that is not a number reads as none asked for, so the reader gets +// the current source rather than an error. +func (h *handler) source(ctx context.Context, article *db.Article, revNum string) (string, error) { + number, err := strconv.Atoi(revNum) + if err != nil { + source, err := h.deps.DB.LatestSource(ctx, article.ID) + if errors.Is(err, db.ErrNotFound) { + return "", nil + } + return source, err + } + source, err := h.deps.DB.SourceAtRevision(ctx, article.ID, number) + if errors.Is(err, db.ErrNotFound) { + return "", nil + } + return source, err +} + +func stringMap(raw string) map[string]string { + if raw == "" { + return nil + } + var out map[string]string + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil + } + return out +} diff --git a/internal/localitem/localitem_test.go b/internal/localitem/localitem_test.go new file mode 100644 index 00000000..b3715178 --- /dev/null +++ b/internal/localitem/localitem_test.go @@ -0,0 +1,139 @@ +package localitem + +import ( + "strings" + "testing" +) + +func TestSplitTakesTwoSegments(t *testing.T) { + cases := []struct { + prefix string + path string + name string + rest string + }{ + {CodePrefix, "/local--code/probe:full/1", "probe:full", "1"}, + {ThemePrefix, "/local--theme/probe:full/style.css", "probe:full", "style.css"}, + {HTMLPrefix, "/local--html/probe:full/abc-def", "probe:full", "abc-def"}, + } + for _, c := range cases { + name, rest, ok := split(c.path, c.prefix) + if !ok || name != c.name || rest != c.rest { + t.Errorf("split(%q) = %q, %q, %v, want %q, %q, true", c.path, name, rest, ok, c.name, c.rest) + } + } +} + +func TestSplitRejectsOtherShapes(t *testing.T) { + for _, path := range []string{ + "/local--code/", + "/local--code/probe:full", + "/local--code/probe:full/", + "/local--code//1", + "/local--code/probe:full/1/2", + "/local--files/probe:full/1", + } { + if _, _, ok := split(path, "/local--code/"); ok { + t.Errorf("split(%q) ok = true, want false", path) + } + } +} + +func TestCodeMime(t *testing.T) { + cases := map[string]string{ + "html": "text/html; charset=utf-8", + "XHTML": "text/html; charset=utf-8", + "js": "text/javascript; charset=utf-8", + "javascript": "text/javascript; charset=utf-8", + "jsx": "text/javascript; charset=utf-8", + "xml": "application/xml; charset=utf-8", + "css": "text/css; charset=utf-8", + "plain": "text/plain; charset=utf-8", + "": "text/plain; charset=utf-8", + } + for language, want := range cases { + if got := codeMime(language); got != want { + t.Errorf("codeMime(%q) = %q, want %q", language, got, want) + } + } +} + +func TestStripNoInclude(t *testing.T) { + cases := map[string]string{ + "a[[noinclude]]b[[/noinclude]]c": "ac", + "a[[noinclude]]b[[/noinclude]]c[[noinclude]]d[[/noinclude]]": "ac", + "plain": "plain", + "a[[noinclude]]b": "a", + "[[noinclude]]b[[/noinclude]]": "", + "a[[/noinclude]]b[[noinclude]]c": "a[[/noinclude]]b", + "[[noinclude]]b[[/noinclude]]c": "c", + } + for source, want := range cases { + if got := stripNoInclude(source); got != want { + t.Errorf("stripNoInclude(%q) = %q, want %q", source, got, want) + } + } +} + +func TestStripNoIncludeManyPairs(t *testing.T) { + source := "x" + strings.Repeat("[[noinclude]]a[[/noinclude]]", 1<<16) + "y" + if got := stripNoInclude(source); got != "xy" { + t.Errorf("stripNoInclude(%d pairs) = %q, want %q", 1<<16, got, "xy") + } +} + +func TestExpandParams(t *testing.T) { + cases := []struct { + source string + params map[string]string + want string + }{ + {"color: {$c};", map[string]string{"c": "red"}, "color: red;"}, + {"{$a} {$b}", map[string]string{"a": "{$b}", "b": "x"}, "{$b} x"}, + {"{$b} {$a}", map[string]string{"a": "{$b}", "b": "x"}, "x {$b}"}, + {"{$missing}", map[string]string{"c": "red"}, "{$missing}"}, + {"plain", nil, "plain"}, + } + for _, c := range cases { + got, ok := expandParams(c.source, c.params) + if !ok || got != c.want { + t.Errorf("expandParams(%q, %v) = %q, %v, want %q, true", c.source, c.params, got, ok, c.want) + } + } +} + +func TestExpandParamsRefusesLargeGrowth(t *testing.T) { + source := strings.Repeat("{$a}", 1024) + value := strings.Repeat("x", 4096) + if got, ok := expandParams(source, map[string]string{"a": value}); ok { + t.Errorf("expandParams(1024 tokens, 4096 bytes) = %d bytes, true, want false", len(got)) + } + if _, ok := expandParams("{$a}", map[string]string{"a": value}); !ok { + t.Errorf("expandParams(1 token, 4096 bytes) ok = false, want true") + } +} + +func TestBlockByHash(t *testing.T) { + blocks := []string{"one", "two"} + const secondHash = "e64f30f9218c6b2848f37c7dfb36593b" + + got, ok := blockByHash(blocks, secondHash) + if !ok || got != "two" { + t.Errorf("blockByHash(%q) = %q, %v, want %q, true", secondHash, got, ok, "two") + } + if got, ok := blockByHash(blocks, "00000000000000000000000000000000"); ok { + t.Errorf("blockByHash(unknown) = %q, true, want \"\", false", got) + } +} + +func TestStringMap(t *testing.T) { + got := stringMap(`{"a": "b"}`) + if len(got) != 1 || got["a"] != "b" { + t.Errorf("stringMap() = %v, want map[a:b]", got) + } + for _, raw := range []string{"", "[]", `{"a": 1}`} { + if got := stringMap(raw); got != nil { + t.Errorf("stringMap(%q) = %v, want nil", raw, got) + } + } +} diff --git a/internal/localitem/theme.go b/internal/localitem/theme.go new file mode 100644 index 00000000..e3daed2d --- /dev/null +++ b/internal/localitem/theme.go @@ -0,0 +1,90 @@ +package localitem + +import ( + "net/http" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/page" + "github.com/WikitTeam/ProjectWikit/internal/renderer" +) + +const themeFile = "style.css" + +const ( + noIncludeOpen = "[[noinclude]]" + noIncludeClose = "[[/noinclude]]" +) + +// The parameters come from the query string, so anyone can make one value +// repeat across a page that names it many times. +const maxParamGrowth = 1 << 20 + +func (h *handler) theme(req *request, rest string) (item, error) { + if rest != themeFile { + return missing(noResource), nil + } + + source, ok := expandParams(stripNoInclude(req.source), stringMap(req.query.Get("includeParams"))) + if !ok { + return item{status: http.StatusRequestEntityTooLarge, contentType: textMime, + body: http.StatusText(http.StatusRequestEntityTooLarge)}, nil + } + + info, err := req.env.PageInfo(req.article) + if err != nil { + return item{}, err + } + vars := req.env.Vars(req.article) + pc := page.NewContext(req.article, req.article, req.params, req.user) + if _, err := req.env.HTML(page.PreRender(source, vars), info, req.env.Callbacks(vars, pc), renderer.ModeArticle); err != nil { + return item{}, err + } + return found(cssMime, pc.AddCSS), nil +} + +// An opening tag with nothing closing it takes the rest of the source with it, +// since what follows was written to stay out of an include either way. +func stripNoInclude(source string) string { + var out strings.Builder + for { + start := strings.Index(source, noIncludeOpen) + if start < 0 { + if out.Len() == 0 { + return source + } + out.WriteString(source) + return out.String() + } + out.WriteString(source[:start]) + end := strings.Index(source[start:], noIncludeClose) + if end < 0 { + return out.String() + } + source = source[start+end+len(noIncludeClose):] + } +} + +// One pass, so a value that spells another parameter stays as written instead +// of being expanded again. +func expandParams(source string, params map[string]string) (string, bool) { + pairs := make([]string, 0, 2*len(params)) + growth := 0 + for key, value := range params { + token := "{$" + key + "}" + n := strings.Count(source, token) + if n == 0 { + continue + } + pairs = append(pairs, token, value) + if extra := len(value) - len(token); extra > 0 { + growth += n * extra + if growth > maxParamGrowth { + return "", false + } + } + } + if len(pairs) == 0 { + return source, true + } + return strings.NewReplacer(pairs...).Replace(source), true +} diff --git a/internal/logfile/logfile.go b/internal/logfile/logfile.go new file mode 100644 index 00000000..7c443b05 --- /dev/null +++ b/internal/logfile/logfile.go @@ -0,0 +1,97 @@ +// Package logfile answers how pwikit keeps its own log a bounded size. +package logfile + +import ( + "errors" + "fmt" + "os" + "strconv" + "sync" +) + +const ( + DefaultLimit = 10 << 20 + DefaultKeep = 5 +) + +type Writer struct { + mu sync.Mutex + path string + limit int64 + keep int + file *os.File + size int64 +} + +func Open(path string, limit int64, keep int) (*Writer, error) { + w := &Writer{path: path, limit: limit, keep: keep} + if err := w.open(); err != nil { + return nil, err + } + return w, nil +} + +func (w *Writer) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return 0, os.ErrClosed + } + if w.size > 0 && w.size+int64(len(p)) > w.limit { + if err := w.rotate(); err != nil { + return 0, err + } + } + n, err := w.file.Write(p) + w.size += int64(n) + return n, err +} + +func (w *Writer) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + +func (w *Writer) open() error { + f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("open log file: %w", err) + } + info, err := f.Stat() + if err != nil { + f.Close() + return err + } + w.file, w.size = f, info.Size() + return nil +} + +// Windows refuses to rename an open file. +func (w *Writer) rotate() error { + if err := w.file.Close(); err != nil { + return err + } + w.file = nil + if err := os.Remove(w.numbered(w.keep)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + for i := w.keep - 1; i >= 1; i-- { + if err := os.Rename(w.numbered(i), w.numbered(i+1)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + if err := os.Rename(w.path, w.numbered(1)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return w.open() +} + +func (w *Writer) numbered(i int) string { + return w.path + "." + strconv.Itoa(i) +} diff --git a/internal/logfile/logfile_test.go b/internal/logfile/logfile_test.go new file mode 100644 index 00000000..2cc6ffe8 --- /dev/null +++ b/internal/logfile/logfile_test.go @@ -0,0 +1,108 @@ +package logfile + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func readFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) err = %v, want nil", filepath.Base(path), err) + } + return string(raw) +} + +func mustWrite(t *testing.T, w *Writer, line string) { + t.Helper() + if _, err := w.Write([]byte(line)); err != nil { + t.Fatalf("Write(%q) err = %v, want nil", line, err) + } +} + +func TestWriteAppendsToWhatIsThere(t *testing.T) { + path := filepath.Join(t.TempDir(), "pwikit.log") + if err := os.WriteFile(path, []byte("old\n"), 0o600); err != nil { + t.Fatal(err) + } + w, err := Open(path, 1<<20, 3) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + mustWrite(t, w, "new\n") + w.Close() + if got := readFile(t, path); got != "old\nnew\n" { + t.Errorf("log = %q, want %q", got, "old\nnew\n") + } +} + +func TestWriteRotatesPastTheLimit(t *testing.T) { + path := filepath.Join(t.TempDir(), "pwikit.log") + w, err := Open(path, 10, 3) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + mustWrite(t, w, "aaaaaaaa\n") + mustWrite(t, w, "bbbbbbbb\n") + w.Close() + if got := readFile(t, path); got != "bbbbbbbb\n" { + t.Errorf("pwikit.log = %q, want %q", got, "bbbbbbbb\n") + } + if got := readFile(t, path+".1"); got != "aaaaaaaa\n" { + t.Errorf("pwikit.log.1 = %q, want %q", got, "aaaaaaaa\n") + } +} + +func TestWriteKeepsOnlySoManyOldFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pwikit.log") + w, err := Open(path, 4, 2) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + for _, line := range []string{"1111", "2222", "3333", "4444"} { + mustWrite(t, w, line) + } + w.Close() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + if got, want := strings.Join(names, ","), "pwikit.log,pwikit.log.1,pwikit.log.2"; got != want { + t.Errorf("files = %s, want %s", got, want) + } + if got := readFile(t, path+".2"); got != "2222" { + t.Errorf("pwikit.log.2 = %q, want %q", got, "2222") + } +} + +func TestWriteLetsOneOversizedLineThrough(t *testing.T) { + path := filepath.Join(t.TempDir(), "pwikit.log") + w, err := Open(path, 4, 2) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + mustWrite(t, w, "a line longer than the limit\n") + w.Close() + if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) { + t.Errorf("Stat(pwikit.log.1) err = %v, want not exist", err) + } +} + +func TestWriteAfterCloseFails(t *testing.T) { + w, err := Open(filepath.Join(t.TempDir(), "pwikit.log"), 1<<20, 2) + if err != nil { + t.Fatalf("Open() err = %v, want nil", err) + } + w.Close() + if _, err := w.Write([]byte("late")); err == nil { + t.Errorf("Write() after Close err = nil, want an error") + } +} diff --git a/internal/mail/mail.go b/internal/mail/mail.go new file mode 100644 index 00000000..7d8f8153 --- /dev/null +++ b/internal/mail/mail.go @@ -0,0 +1,137 @@ +// Package mail delivers the few messages the site sends by itself. +package mail + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log/slog" + "mime" + "net" + "net/smtp" + "strings" + "time" +) + +const dialTimeout = 10 * time.Second + +var ErrNoSender = errors.New("mail: no from address is configured") + +type Sender interface { + Send(ctx context.Context, to []string, subject, body string) error +} + +type Config struct { + Host string + Port string + Username string + Password string + UseTLS bool + Implicit bool + From string +} + +type Console struct { + Log *slog.Logger +} + +var _ Sender = (*Console)(nil) + +func (c *Console) Send(_ context.Context, to []string, subject, body string) error { + log := c.Log + if log == nil { + log = slog.Default() + } + log.Info("mail not sent, no mail host configured", "to", strings.Join(to, ", "), "subject", subject, "body", body) + return nil +} + +type SMTP struct { + Config +} + +var _ Sender = (*SMTP)(nil) + +func New(c Config) Sender { + if c.Host == "" { + return &Console{} + } + return &SMTP{Config: c} +} + +func (s *SMTP) Send(ctx context.Context, to []string, subject, body string) error { + if s.From == "" { + return ErrNoSender + } + address := net.JoinHostPort(s.Host, s.Port) + conn, err := s.dial(ctx, address) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, s.Host) + if err != nil { + conn.Close() + return fmt.Errorf("open mail session with %s: %w", address, err) + } + defer client.Close() + + if s.UseTLS && !s.Implicit { + if err := client.StartTLS(&tls.Config{ServerName: s.Host}); err != nil { + return fmt.Errorf("start TLS with %s: %w", address, err) + } + } + if s.Username != "" { + if err := client.Auth(smtp.PlainAuth("", s.Username, s.Password, s.Host)); err != nil { + return fmt.Errorf("authenticate with %s: %w", address, err) + } + } + if err := client.Mail(s.From); err != nil { + return fmt.Errorf("send from %s: %w", s.From, err) + } + for _, one := range to { + if err := client.Rcpt(one); err != nil { + return fmt.Errorf("send to %s: %w", one, err) + } + } + writer, err := client.Data() + if err != nil { + return fmt.Errorf("write message: %w", err) + } + if _, err := writer.Write(message(s.From, to, subject, body)); err != nil { + return fmt.Errorf("write message: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("write message: %w", err) + } + return client.Quit() +} + +func (s *SMTP) dial(ctx context.Context, address string) (net.Conn, error) { + dialer := &net.Dialer{Timeout: dialTimeout} + if s.Implicit { + conn, err := (&tls.Dialer{NetDialer: dialer, Config: &tls.Config{ServerName: s.Host}}). + DialContext(ctx, "tcp", address) + if err != nil { + return nil, fmt.Errorf("dial mail host %s over TLS: %w", address, err) + } + return conn, nil + } + conn, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return nil, fmt.Errorf("dial mail host %s: %w", address, err) + } + return conn, nil +} + +func message(from string, to []string, subject, body string) []byte { + var b strings.Builder + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + strings.Join(to, ", ") + "\r\n") + b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + b.WriteString("\r\n") + b.WriteString(strings.ReplaceAll(body, "\n", "\r\n")) + return []byte(b.String()) +} diff --git a/internal/media/media.go b/internal/media/media.go new file mode 100644 index 00000000..71a36363 --- /dev/null +++ b/internal/media/media.go @@ -0,0 +1,268 @@ +// Package media serves the uploaded files under /local--files/. +package media + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/WikitTeam/ProjectWikit/internal/db" + "github.com/WikitTeam/ProjectWikit/internal/escape" + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/site" +) + +const ( + Prefix = "/local--files/" + notFoundBody = "Not found" + defaultMime = "application/octet-stream" + defaultHTMLMime = "text/html; charset=utf-8" +) + +// Attachments resolves an article attachment's on-disk names. +type Attachments interface { + ArticleFile(ctx context.Context, siteID int64, articleRef, fileName string) (*db.ArticleFile, error) +} + +type Handler struct { + root string + files Attachments +} + +var _ http.Handler = (*Handler)(nil) + +// New serves out of root, which is files/ itself rather than its media/ +// subdirectory; the request decides which of the two it lands in. +func New(root string, files Attachments) *Handler { + return &Handler{root: root, files: files} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + rest, ok := strings.CutPrefix(r.URL.Path, Prefix) + if !ok || rest == "" { + notFound(w) + return + } + + full, mimeType, size, ok := h.locate(r.Context(), rest) + if !ok { + notFound(w) + return + } + + f, err := os.Open(full) + if err != nil { + notFound(w) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil || info.IsDir() { + notFound(w) + return + } + + responseMime := mimeType + if mimeType == "" { + responseMime = defaultHTMLMime + mimeType, _ = guessType(filepath.Base(full)) + if mimeType == "" { + mimeType = defaultMime + } + } + + chunk := chunkSizeFor(mimeType) + if chunk == 0 { + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", disposition(filepath.Base(full))) + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + copyRange(w, f, 0, info.Size()) + } + return + } + + if size == 0 { + size = info.Size() + } + if !modifiedSince(r.Header.Get("If-Modified-Since"), info.ModTime()) { + w.WriteHeader(http.StatusNotModified) + return + } + + begin, end, ok := rangeBounds(r.Header.Get("Range"), chunk, size) + if !ok { + w.Header().Set("Content-Type", defaultHTMLMime) + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + + w.Header().Set("Content-Type", responseMime) + w.Header().Set("Last-Modified", info.ModTime().UTC().Format(http.TimeFormat)) + w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range") + w.Header().Set("Content-Disposition", "inline") + w.Header().Set("Accept-Ranges", "bytes") + + if begin >= end || end == 0 { + // The length has to be zero here, since no body follows and a full one would + // break the framing. + w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", size)) + w.Header().Set("Content-Length", "0") + w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) + return + } + + // end is exclusive here and inclusive in the header, so this is a byte + // short of what the client asked for. + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", begin, end-1, size)) + w.Header().Set("Content-Length", strconv.FormatInt(end-begin, 10)) + w.WriteHeader(http.StatusPartialContent) + if r.Method != http.MethodHead { + copyRange(w, f, begin, end-begin) + } +} + +// locate turns the URL tail into a path on disk; a type and size come back +// only from the attachment table. +func (h *Handler) locate(ctx context.Context, rest string) (full, mimeType string, size int64, ok bool) { + segments := strings.Split(rest, "/") + root := h.root + + if !strings.HasPrefix(rest, "-/") { + root = filepath.Join(root, "media") + // Uploads live under a pair of UUIDs, so a two-segment path that + // resolves to nothing is read as article and attachment names. + if len(segments) == 2 && !exists(filepath.Join(root, rest)) { + if f, err := h.files.ArticleFile(ctx, siteID(ctx), segments[0], segments[1]); err == nil { + segments = []string{f.ArticleMediaName, f.MediaName} + mimeType, size = f.MimeType, f.Size + } else if !errors.Is(err, db.ErrNotFound) { + return "", "", 0, false + } + } + } + + for i, s := range segments { + segments[i] = QuoteName(s) + } + full, err := paths.Resolve(root, filepath.Join(segments...)) + if err != nil { + return "", "", 0, false + } + return full, mimeType, size, true +} + +// Not urlencoding. Widening this set would put new uploads in directories where +// the files already on disk are not. +func QuoteName(s string) string { + return strings.NewReplacer(":", "%3A", "/", "%2F", "?", "%3F").Replace(s) +} + +func exists(name string) bool { + _, err := os.Stat(name) + return err == nil +} + +// rangeBounds returns a half-open [begin, end), and a false third value is a +// 416 rather than a raised error. +func rangeBounds(header string, chunk, size int64) (begin, end int64, ok bool) { + if header == "" { + return 0, min(chunk, size), true + } + unit, spec, found := strings.Cut(header, "=") + if !found || unit != "bytes" { + return 0, 0, false + } + first, last, found := strings.Cut(spec, "-") + if !found { + return 0, 0, false + } + // An empty bound reads as 0, and 0 then reads as "not given" a line later, + // which is why bytes=-50 returns the first 50 bytes rather than the last. + if begin, ok = parseBound(first); !ok { + return 0, 0, false + } + if end, ok = parseBound(last); !ok { + return 0, 0, false + } + + if begin != 0 { + begin = min(begin, size) + } + maxEnd := min(begin+chunk, size) + if end == 0 { + end = maxEnd + } else { + end = min(end, maxEnd) + } + return begin, min(end, size), true +} + +func parseBound(s string) (int64, bool) { + if s == "" { + return 0, true + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, false + } + return n, true +} + +// modifiedSince counts anything it cannot parse as modified. +func modifiedSince(header string, mtime time.Time) bool { + if header == "" { + return true + } + since, err := http.ParseTime(strings.TrimSpace(strings.Split(header, ";")[0])) + if err != nil { + return true + } + return mtime.Unix() > since.Unix() +} + +func copyRange(w http.ResponseWriter, f *os.File, offset, length int64) { + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return + } + _, _ = io.Copy(w, io.LimitReader(f, length)) +} + +// disposition moves a name that is not ASCII to the RFC 5987 form instead of +// quoting it. +func disposition(name string) string { + for i := 0; i < len(name); i++ { + if name[i] > 127 { + return "inline; filename*=utf-8''" + escape.URLQuote(name) + } + } + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(name) + return `inline; filename="` + escaped + `"` +} + +func notFound(w http.ResponseWriter) { + w.Header().Set("Content-Type", defaultHTMLMime) + w.Header().Set("Content-Length", strconv.Itoa(len(notFoundBody))) + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(notFoundBody)) +} + +func siteID(ctx context.Context) int64 { + if current := site.FromContext(ctx); current != nil { + return current.ID + } + return 0 +} diff --git a/internal/media/media_test.go b/internal/media/media_test.go new file mode 100644 index 00000000..53918539 --- /dev/null +++ b/internal/media/media_test.go @@ -0,0 +1,332 @@ +package media + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/db" +) + +type fakeFiles map[string]*db.ArticleFile + +func (f fakeFiles) ArticleFile(_ context.Context, _ int64, articleRef, fileName string) (*db.ArticleFile, error) { + if af, ok := f[articleRef+"/"+fileName]; ok { + return af, nil + } + return nil, db.ErrNotFound +} + +// probe is the same 300-byte body the Django oracle was recorded against. +func probe() []byte { + b := make([]byte, 300) + for i := range b { + b[i] = byte(i % 251) + } + return b +} + +func newTestHandler(t *testing.T, files Attachments) (*Handler, string) { + t.Helper() + root := t.TempDir() + for _, dir := range []string{"-/probe", "media/article-uuid"} { + if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(dir)), 0o755); err != nil { + t.Fatalf("MkdirAll(%q) err = %v, want nil", dir, err) + } + } + write := func(name string, data []byte) { + if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), data, 0o644); err != nil { + t.Fatalf("WriteFile(%q) err = %v, want nil", name, err) + } + } + for _, name := range []string{"-/probe/a.pdf", "-/probe/a.txt", "-/probe/a.txt.gz", "-/probe/a.bin"} { + write(name, probe()) + } + write("-/probe/empty", nil) + write("media/article-uuid/file-uuid", probe()) + + if files == nil { + files = fakeFiles{} + } + return New(root, files), root +} + +func get(t *testing.T, h *Handler, path string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodGet, path, nil) + for k, v := range headers { + r.Header.Set(k, v) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func TestServeHTTPRangedDefaults(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/a.pdf", nil) + + if w.Code != http.StatusPartialContent { + t.Errorf("GET a.pdf = %d, want %d", w.Code, http.StatusPartialContent) + } + if got, want := w.Header().Get("Content-Range"), "bytes 0-299/300"; got != want { + t.Errorf("Content-Range = %q, want %q", got, want) + } + if got, want := w.Header().Get("Accept-Ranges"), "bytes"; got != want { + t.Errorf("Accept-Ranges = %q, want %q", got, want) + } + if got, want := w.Header().Get("Content-Disposition"), "inline"; got != want { + t.Errorf("Content-Disposition = %q, want %q", got, want) + } + if got, want := w.Body.Len(), 300; got != want { + t.Errorf("body length = %d, want %d", got, want) + } +} + +func TestServeHTTPRangedContentTypeIsDjangoDefault(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/a.pdf", nil) + + // The guessed type never reaches the response. + if got, want := w.Header().Get("Content-Type"), defaultHTMLMime; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } +} + +func TestServeHTTPNonRanged(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/a.txt", nil) + + if w.Code != http.StatusOK { + t.Errorf("GET a.txt = %d, want %d", w.Code, http.StatusOK) + } + if got, want := w.Header().Get("Content-Type"), "text/plain"; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } + if got, want := w.Header().Get("Content-Disposition"), `inline; filename="a.txt"`; got != want { + t.Errorf("Content-Disposition = %q, want %q", got, want) + } + // The encoding is guessed and then dropped. + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want %q", got, "") + } +} + +func TestServeHTTPGzipExtensionKeepsInnerType(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/a.txt.gz", nil) + + if got, want := w.Header().Get("Content-Type"), "text/plain"; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } + if got := w.Header().Get("Content-Encoding"); got != "" { + t.Errorf("Content-Encoding = %q, want %q", got, "") + } +} + +func TestServeHTTPRange(t *testing.T) { + h, _ := newTestHandler(t, nil) + tests := []struct { + header string + wantCode int + wantRange string + wantBodyLen int + }{ + // One byte short of what was asked for. + {"bytes=0-99", http.StatusPartialContent, "bytes 0-98/300", 99}, + {"bytes=100-199", http.StatusPartialContent, "bytes 100-198/300", 99}, + {"bytes=250-", http.StatusPartialContent, "bytes 250-299/300", 50}, + // A zero end and a suffix range both read as "not given". + {"bytes=0-0", http.StatusPartialContent, "bytes 0-299/300", 300}, + {"bytes=-50", http.StatusPartialContent, "bytes 0-49/300", 50}, + {"bytes=0-99999", http.StatusPartialContent, "bytes 0-299/300", 300}, + // The last byte of a file is unreachable. + {"bytes=299-299", http.StatusRequestedRangeNotSatisfiable, "bytes */300", 0}, + {"bytes=300-400", http.StatusRequestedRangeNotSatisfiable, "bytes */300", 0}, + } + for _, tt := range tests { + t.Run(tt.header, func(t *testing.T) { + w := get(t, h, Prefix+"-/probe/a.pdf", map[string]string{"Range": tt.header}) + if w.Code != tt.wantCode { + t.Errorf("GET a.pdf Range=%q = %d, want %d", tt.header, w.Code, tt.wantCode) + } + if got := w.Header().Get("Content-Range"); got != tt.wantRange { + t.Errorf("Content-Range = %q, want %q", got, tt.wantRange) + } + if got := w.Body.Len(); got != tt.wantBodyLen { + t.Errorf("body length = %d, want %d", got, tt.wantBodyLen) + } + }) + } +} + +func TestServeHTTPRangeBodyIsTheRequestedSlice(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/a.pdf", map[string]string{"Range": "bytes=100-199"}) + + if got, want := w.Body.String(), string(probe()[100:199]); got != want { + t.Errorf("body = %d bytes, want the 99 bytes at offset 100", len(got)) + } +} + +func TestServeHTTPRangeUnsatisfiableSendsNoBody(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/a.pdf", map[string]string{"Range": "bytes=300-400"}) + + // Django leaves Content-Length at the file size here. + if got, want := w.Header().Get("Content-Length"), "0"; got != want { + t.Errorf("Content-Length = %q, want %q", got, want) + } +} + +func TestServeHTTPMalformedRange(t *testing.T) { + h, _ := newTestHandler(t, nil) + // Django raises on all but the first of these. + for _, header := range []string{"items=0-10", "bogus", "bytes=abc", "bytes=0-10,20-30", "bytes=0-10-20"} { + t.Run(header, func(t *testing.T) { + w := get(t, h, Prefix+"-/probe/a.pdf", map[string]string{"Range": header}) + if w.Code != http.StatusRequestedRangeNotSatisfiable { + t.Errorf("GET a.pdf Range=%q = %d, want %d", header, w.Code, http.StatusRequestedRangeNotSatisfiable) + } + }) + } +} + +func TestServeHTTPEmptyFileIsUnsatisfiable(t *testing.T) { + h, _ := newTestHandler(t, nil) + w := get(t, h, Prefix+"-/probe/empty", nil) + + if w.Code != http.StatusRequestedRangeNotSatisfiable { + t.Errorf("GET empty = %d, want %d", w.Code, http.StatusRequestedRangeNotSatisfiable) + } + if got, want := w.Header().Get("Content-Range"), "bytes */0"; got != want { + t.Errorf("Content-Range = %q, want %q", got, want) + } +} + +func TestServeHTTPConditionalOnlyOnRangedPath(t *testing.T) { + h, _ := newTestHandler(t, nil) + future := "Mon, 02 Jan 2100 00:00:00 GMT" + + w := get(t, h, Prefix+"-/probe/a.pdf", map[string]string{"If-Modified-Since": future}) + if w.Code != http.StatusNotModified { + t.Errorf("GET a.pdf If-Modified-Since=future = %d, want %d", w.Code, http.StatusNotModified) + } + // FileResponse never looks at the header. + w = get(t, h, Prefix+"-/probe/a.txt", map[string]string{"If-Modified-Since": future}) + if w.Code != http.StatusOK { + t.Errorf("GET a.txt If-Modified-Since=future = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestServeHTTPAttachmentRemap(t *testing.T) { + files := fakeFiles{"main/report.pdf": { + ArticleMediaName: "article-uuid", + MediaName: "file-uuid", + MimeType: "application/pdf", + Size: 300, + }} + h, _ := newTestHandler(t, files) + w := get(t, h, Prefix+"main/report.pdf", nil) + + if w.Code != http.StatusPartialContent { + t.Errorf("GET main/report.pdf = %d, want %d", w.Code, http.StatusPartialContent) + } + // The remapped branch is the only one whose type reaches the response. + if got, want := w.Header().Get("Content-Type"), "application/pdf"; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } + if got, want := w.Header().Get("Content-Range"), "bytes 0-299/300"; got != want { + t.Errorf("Content-Range = %q, want %q", got, want) + } +} + +func TestServeHTTPExistingPathSkipsRemap(t *testing.T) { + files := fakeFiles{"article-uuid/file-uuid": { + ArticleMediaName: "wrong", MediaName: "wrong", MimeType: "application/pdf", Size: 1, + }} + h, _ := newTestHandler(t, files) + w := get(t, h, Prefix+"article-uuid/file-uuid", nil) + + if w.Code != http.StatusPartialContent { + t.Errorf("GET article-uuid/file-uuid = %d, want %d", w.Code, http.StatusPartialContent) + } + if got, want := w.Header().Get("Content-Type"), defaultHTMLMime; got != want { + t.Errorf("Content-Type = %q, want %q", got, want) + } +} + +func TestServeHTTPNotFound(t *testing.T) { + h, _ := newTestHandler(t, nil) + for _, path := range []string{ + Prefix + "-/probe/nope.pdf", + Prefix + "nope/nope", + Prefix + "-/probe", + Prefix, + } { + t.Run(path, func(t *testing.T) { + w := get(t, h, path, nil) + if w.Code != http.StatusNotFound { + t.Errorf("GET %q = %d, want %d", path, w.Code, http.StatusNotFound) + } + if got, want := w.Body.String(), notFoundBody; got != want { + t.Errorf("body = %q, want %q", got, want) + } + }) + } +} + +func TestServeHTTPRejectsTraversal(t *testing.T) { + h, root := newTestHandler(t, nil) + outside := filepath.Join(filepath.Dir(root), "outside.txt") + if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil { + t.Fatalf("WriteFile(%q) err = %v, want nil", outside, err) + } + t.Cleanup(func() { os.Remove(outside) }) + + // Django serves every one of these. + for _, path := range []string{ + Prefix + "-/probe/../../../outside.txt", + Prefix + "-/../../outside.txt", + Prefix + "article-uuid/../../../outside.txt", + } { + t.Run(path, func(t *testing.T) { + w := get(t, h, path, nil) + if w.Code != http.StatusNotFound { + t.Errorf("GET %q = %d, want %d", path, w.Code, http.StatusNotFound) + } + if strings.Contains(w.Body.String(), "secret") { + t.Errorf("GET %q served a file outside the root", path) + } + }) + } +} + +func TestServeHTTPRejectsNonReadMethods(t *testing.T) { + h, _ := newTestHandler(t, nil) + r := httptest.NewRequest(http.MethodPost, Prefix+"-/probe/a.txt", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("POST a.txt = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestServeHTTPHeadSendsNoBody(t *testing.T) { + h, _ := newTestHandler(t, nil) + r := httptest.NewRequest(http.MethodHead, Prefix+"-/probe/a.pdf", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if got, want := w.Header().Get("Content-Length"), "300"; got != want { + t.Errorf("Content-Length = %q, want %q", got, want) + } + if got := w.Body.Len(); got != 0 { + t.Errorf("body length = %d, want 0", got) + } +} diff --git a/internal/media/mime.go b/internal/media/mime.go new file mode 100644 index 00000000..83e95663 --- /dev/null +++ b/internal/media/mime.go @@ -0,0 +1,74 @@ +package media + +import "strings" + +// An empty type means unknown, and the caller supplies the fallback. +func guessType(name string) (mimeType, encoding string) { + base, ext := splitExt(name) + for { + mapped, ok := mimeSuffixes[strings.ToLower(ext)] + if !ok { + break + } + base, ext = splitExt(base + mapped) + } + if enc, ok := mimeEncodings[strings.ToLower(ext)]; ok { + encoding = enc + _, ext = splitExt(base) + } + return mimeTypes[strings.ToLower(ext)], encoding +} + +// splitExt is os.path.splitext: ".gitkeep" splits to (".gitkeep", ""). +func splitExt(p string) (base, ext string) { + sep := strings.LastIndexByte(p, '/') + dot := strings.LastIndexByte(p, '.') + if dot > sep { + for i := sep + 1; i < dot; i++ { + if p[i] != '.' { + return p[:dot], p[dot:] + } + } + } + return p, "" +} + +// matchMime splits on the first slash only, so a type carrying parameters +// keeps them in the subtype and matches nothing. +func matchMime(mime1, mime2 string) bool { + type1, subtype1, ok1 := strings.Cut(mime1, "/") + type2, subtype2, ok2 := strings.Cut(mime2, "/") + if !ok1 || !ok2 { + return false + } + if type1 == "*" && subtype1 == "*" { + return true + } + if type1 != type2 { + return false + } + return subtype1 == "*" || subtype1 == subtype2 || subtype2 == "*" +} + +// Order is load-bearing: the first match wins. +var rangedMime = []struct { + mime string + chunk int64 +}{ + {"audio/*", 2097152}, + {"video/*", 4194304}, + {"application/octet-stream", 4194304}, + {"application/zip", 8388608}, + {"application/gzip", 8388608}, + {"application/x-tar", 8388608}, + {"application/pdf", 1048576}, +} + +func chunkSizeFor(mimeType string) int64 { + for _, r := range rangedMime { + if matchMime(mimeType, r.mime) { + return r.chunk + } + } + return 0 +} diff --git a/internal/media/mime_test.go b/internal/media/mime_test.go new file mode 100644 index 00000000..4113b896 --- /dev/null +++ b/internal/media/mime_test.go @@ -0,0 +1,112 @@ +package media + +import "testing" + +func TestSplitExt(t *testing.T) { + tests := []struct{ in, base, ext string }{ + {"a.pdf", "a", ".pdf"}, + {"a.txt.gz", "a.txt", ".gz"}, + {"archive", "archive", ""}, + {".gitkeep", ".gitkeep", ""}, + {"..hidden", "..hidden", ""}, + {"dir.d/file", "dir.d/file", ""}, + {"dir.d/file.txt", "dir.d/file", ".txt"}, + {"a.", "a", "."}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + base, ext := splitExt(tt.in) + if base != tt.base || ext != tt.ext { + t.Errorf("splitExt(%q) = %q, %q, want %q, %q", tt.in, base, ext, tt.base, tt.ext) + } + }) + } +} + +func TestGuessType(t *testing.T) { + tests := []struct{ name, mime, encoding string }{ + {"a.pdf", "application/pdf", ""}, + {"a.PDF", "application/pdf", ""}, + {"a.txt", "text/plain", ""}, + {"a.txt.gz", "text/plain", "gzip"}, + {"a.tgz", "application/x-tar", "gzip"}, + {"a.svgz", "image/svg+xml", "gzip"}, + {"manage.py", "text/x-python", ""}, + {"a.mp4", "video/mp4", ""}, + {"a.bin", "application/octet-stream", ""}, + {"a.unknownext", "", ""}, + {"noextension", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mime, encoding := guessType(tt.name) + if mime != tt.mime || encoding != tt.encoding { + t.Errorf("guessType(%q) = %q, %q, want %q, %q", tt.name, mime, encoding, tt.mime, tt.encoding) + } + }) + } +} + +func TestMatchMime(t *testing.T) { + tests := []struct { + mime1, mime2 string + want bool + }{ + {"audio/mpeg", "audio/*", true}, + {"video/mp4", "video/*", true}, + {"application/pdf", "application/pdf", true}, + {"application/zip", "application/octet-stream", false}, + {"audio/mpeg", "video/*", false}, + {"text/html; charset=utf-8", "text/html", false}, + {"anything", "audio/*", false}, + } + for _, tt := range tests { + t.Run(tt.mime1+" vs "+tt.mime2, func(t *testing.T) { + if got := matchMime(tt.mime1, tt.mime2); got != tt.want { + t.Errorf("matchMime(%q, %q) = %v, want %v", tt.mime1, tt.mime2, got, tt.want) + } + }) + } +} + +func TestChunkSizeFor(t *testing.T) { + tests := []struct { + mime string + want int64 + }{ + {"audio/mpeg", 2097152}, + {"video/mp4", 4194304}, + {"application/octet-stream", 4194304}, + {"application/zip", 8388608}, + {"application/gzip", 8388608}, + {"application/x-tar", 8388608}, + {"application/pdf", 1048576}, + // The image entry is disabled upstream. + {"image/png", 0}, + {"text/plain", 0}, + {defaultHTMLMime, 0}, + } + for _, tt := range tests { + t.Run(tt.mime, func(t *testing.T) { + if got := chunkSizeFor(tt.mime); got != tt.want { + t.Errorf("chunkSizeFor(%q) = %d, want %d", tt.mime, got, tt.want) + } + }) + } +} + +func TestDisposition(t *testing.T) { + tests := []struct{ name, want string }{ + {"a.txt", `inline; filename="a.txt"`}, + {`quote".txt`, `inline; filename="quote\".txt"`}, + {`back\slash.txt`, `inline; filename="back\\slash.txt"`}, + {"报告.pdf", "inline; filename*=utf-8''%E6%8A%A5%E5%91%8A.pdf"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := disposition(tt.name); got != tt.want { + t.Errorf("disposition(%q) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} diff --git a/internal/media/mimetable.go b/internal/media/mimetable.go new file mode 100644 index 00000000..a78302a0 --- /dev/null +++ b/internal/media/mimetable.go @@ -0,0 +1,178 @@ +package media + +// mime.TypeByExtension is a different table and reads the Windows registry, +// which would make a file's Content-Type depend on the machine. + +var mimeTypes = map[string]string{ + ".3g2": "audio/3gpp2", + ".3gp": "audio/3gpp", + ".3gpp": "audio/3gpp", + ".3gpp2": "audio/3gpp2", + ".a": "application/octet-stream", + ".aac": "audio/aac", + ".adts": "audio/aac", + ".ai": "application/postscript", + ".aif": "audio/x-aiff", + ".aifc": "audio/x-aiff", + ".aiff": "audio/x-aiff", + ".ass": "audio/aac", + ".au": "audio/basic", + ".avi": "video/x-msvideo", + ".avif": "image/avif", + ".bat": "text/plain", + ".bcpio": "application/x-bcpio", + ".bin": "application/octet-stream", + ".bmp": "image/bmp", + ".c": "text/plain", + ".cdf": "application/x-netcdf", + ".cpio": "application/x-cpio", + ".csh": "application/x-csh", + ".css": "text/css", + ".csv": "text/csv", + ".dll": "application/octet-stream", + ".doc": "application/msword", + ".dot": "application/msword", + ".dvi": "application/x-dvi", + ".eml": "message/rfc822", + ".eps": "application/postscript", + ".etx": "text/x-setext", + ".exe": "application/octet-stream", + ".gif": "image/gif", + ".gtar": "application/x-gtar", + ".h": "text/plain", + ".h5": "application/x-hdf5", + ".hdf": "application/x-hdf", + ".heic": "image/heic", + ".heif": "image/heif", + ".htm": "text/html", + ".html": "text/html", + ".ico": "image/vnd.microsoft.icon", + ".ief": "image/ief", + ".jpe": "image/jpeg", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript", + ".json": "application/json", + ".ksh": "text/plain", + ".latex": "application/x-latex", + ".loas": "audio/aac", + ".m1v": "video/mpeg", + ".m3u": "application/vnd.apple.mpegurl", + ".m3u8": "application/vnd.apple.mpegurl", + ".man": "application/x-troff-man", + ".markdown": "text/markdown", + ".md": "text/markdown", + ".me": "application/x-troff-me", + ".mht": "message/rfc822", + ".mhtml": "message/rfc822", + ".mif": "application/x-mif", + ".mjs": "text/javascript", + ".mov": "video/quicktime", + ".movie": "video/x-sgi-movie", + ".mp2": "audio/mpeg", + ".mp3": "audio/mpeg", + ".mp4": "video/mp4", + ".mpa": "video/mpeg", + ".mpe": "video/mpeg", + ".mpeg": "video/mpeg", + ".mpg": "video/mpeg", + ".ms": "application/x-troff-ms", + ".n3": "text/n3", + ".nc": "application/x-netcdf", + ".nq": "application/n-quads", + ".nt": "application/n-triples", + ".nws": "message/rfc822", + ".o": "application/octet-stream", + ".obj": "application/octet-stream", + ".oda": "application/oda", + ".opus": "audio/opus", + ".p12": "application/x-pkcs12", + ".p7c": "application/pkcs7-mime", + ".pbm": "image/x-portable-bitmap", + ".pdf": "application/pdf", + ".pfx": "application/x-pkcs12", + ".pgm": "image/x-portable-graymap", + ".pl": "text/plain", + ".png": "image/png", + ".pnm": "image/x-portable-anymap", + ".pot": "application/vnd.ms-powerpoint", + ".ppa": "application/vnd.ms-powerpoint", + ".ppm": "image/x-portable-pixmap", + ".pps": "application/vnd.ms-powerpoint", + ".ppt": "application/vnd.ms-powerpoint", + ".ps": "application/postscript", + ".pwz": "application/vnd.ms-powerpoint", + ".py": "text/x-python", + ".pyc": "application/x-python-code", + ".pyo": "application/x-python-code", + ".qt": "video/quicktime", + ".ra": "audio/x-pn-realaudio", + ".ram": "application/x-pn-realaudio", + ".ras": "image/x-cmu-raster", + ".rdf": "application/xml", + ".rgb": "image/x-rgb", + ".roff": "application/x-troff", + ".rst": "text/x-rst", + ".rtf": "text/rtf", + ".rtx": "text/richtext", + ".sgm": "text/x-sgml", + ".sgml": "text/x-sgml", + ".sh": "application/x-sh", + ".shar": "application/x-shar", + ".snd": "audio/basic", + ".so": "application/octet-stream", + ".src": "application/x-wais-source", + ".srt": "text/plain", + ".sv4cpio": "application/x-sv4cpio", + ".sv4crc": "application/x-sv4crc", + ".svg": "image/svg+xml", + ".swf": "application/x-shockwave-flash", + ".t": "application/x-troff", + ".tar": "application/x-tar", + ".tcl": "application/x-tcl", + ".tex": "application/x-tex", + ".texi": "application/x-texinfo", + ".texinfo": "application/x-texinfo", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".tr": "application/x-troff", + ".trig": "application/trig", + ".tsv": "text/tab-separated-values", + ".txt": "text/plain", + ".ustar": "application/x-ustar", + ".vcf": "text/x-vcard", + ".vtt": "text/vtt", + ".wasm": "application/wasm", + ".wav": "audio/x-wav", + ".webm": "video/webm", + ".webmanifest": "application/manifest+json", + ".webp": "image/webp", + ".wiz": "application/msword", + ".wsdl": "application/xml", + ".xbm": "image/x-xbitmap", + ".xlb": "application/vnd.ms-excel", + ".xls": "application/vnd.ms-excel", + ".xml": "text/xml", + ".xpdl": "application/xml", + ".xpm": "image/x-xpixmap", + ".xsl": "application/xml", + ".xwd": "image/x-xwindowdump", + ".zip": "application/zip", +} + +var mimeEncodings = map[string]string{ + ".Z": "compress", + ".br": "br", + ".bz2": "bzip2", + ".gz": "gzip", + ".xz": "xz", +} + +var mimeSuffixes = map[string]string{ + ".svgz": ".svg.gz", + ".taz": ".tar.gz", + ".tbz2": ".tar.bz2", + ".tgz": ".tar.gz", + ".txz": ".tar.xz", + ".tz": ".tar.gz", +} diff --git a/internal/media/resized.go b/internal/media/resized.go new file mode 100644 index 00000000..774eff17 --- /dev/null +++ b/internal/media/resized.go @@ -0,0 +1,110 @@ +package media + +import ( + "context" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/WikitTeam/ProjectWikit/internal/paths" + "github.com/WikitTeam/ProjectWikit/internal/thumb" +) + +const ( + ResizedPrefix = "/local--resized-images/" + resizedDir = "resized" + jpegMime = "image/jpeg" +) + +type ResizedHandler struct { + root string + files Attachments +} + +var _ http.Handler = (*ResizedHandler)(nil) + +func NewResized(root string, files Attachments) *ResizedHandler { + return &ResizedHandler{root: root, files: files} +} + +func (h *ResizedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + rest, ok := strings.CutPrefix(r.URL.Path, ResizedPrefix) + if !ok { + notFound(w) + return + } + // The file name keeps its own extension and the size carries another, so + // the path has one more segment than an attachment's does. + segments := strings.Split(rest, "/") + if len(segments) != 3 { + notFound(w) + return + } + size, ok := thumb.Lookup(strings.TrimSuffix(segments[2], filepath.Ext(segments[2]))) + if !ok { + notFound(w) + return + } + + body, err := h.image(r.Context(), segments[0], segments[1], size) + if err != nil { + notFound(w) + return + } + + w.Header().Set("Content-Type", jpegMime) + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Header().Set("Content-Disposition", "inline") + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = w.Write(body) + } +} + +func (h *ResizedHandler) image(ctx context.Context, articleRef, fileName string, size thumb.Size) ([]byte, error) { + file, err := h.files.ArticleFile(ctx, siteID(ctx), articleRef, fileName) + if err != nil { + return nil, err + } + if !strings.HasPrefix(file.MimeType, "image/") { + return nil, os.ErrNotExist + } + + stored := filepath.Join(QuoteName(file.ArticleMediaName), QuoteName(file.MediaName)) + cache, err := paths.Resolve(filepath.Join(h.root, resizedDir), filepath.Join(stored, size.Name+".jpg")) + if err != nil { + return nil, err + } + if body, err := os.ReadFile(cache); err == nil { + return body, nil + } + + original, err := paths.Resolve(filepath.Join(h.root, "media"), stored) + if err != nil { + return nil, err + } + src, err := os.Open(original) + if err != nil { + return nil, err + } + defer src.Close() + + body, err := thumb.Generate(src, size) + if err != nil { + return nil, err + } + + // A cache that cannot be written still answers the request, because the + // scaled copy is derived and losing it costs only the work to redo it. + if err := os.MkdirAll(filepath.Dir(cache), 0o755); err == nil { + _ = os.WriteFile(cache, body, 0o644) + } + return body, nil +} diff --git a/internal/media/resized_test.go b/internal/media/resized_test.go new file mode 100644 index 00000000..c84dbbe1 --- /dev/null +++ b/internal/media/resized_test.go @@ -0,0 +1,133 @@ +package media + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/WikitTeam/ProjectWikit/internal/db" +) + +func newResizedHandler(t *testing.T, mime string) (*ResizedHandler, string) { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, "media", "article-uuid") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll() err = %v, want nil", err) + } + + img := image.NewRGBA(image.Rect(0, 0, 1072, 876)) + for y := 0; y < 876; y++ { + for x := 0; x < 1072; x++ { + img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 64, A: 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, nil); err != nil { + t.Fatalf("jpeg.Encode() err = %v, want nil", err) + } + if err := os.WriteFile(filepath.Join(dir, "file-uuid"), buf.Bytes(), 0o644); err != nil { + t.Fatalf("WriteFile() err = %v, want nil", err) + } + + files := fakeFiles{"probe/photo.jpg": &db.ArticleFile{ + ArticleMediaName: "article-uuid", + MediaName: "file-uuid", + MimeType: mime, + Size: int64(buf.Len()), + }} + return NewResized(root, files), root +} + +func getResized(t *testing.T, h *ResizedHandler, path string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + return w +} + +func TestResizedServesTheRequestedSize(t *testing.T) { + h, _ := newResizedHandler(t, "image/jpeg") + + w := getResized(t, h, "/local--resized-images/probe/photo.jpg/medium.jpg") + if w.Code != http.StatusOK { + t.Fatalf("GET medium = %d, want %d", w.Code, http.StatusOK) + } + if got := w.Header().Get("Content-Type"); got != "image/jpeg" { + t.Errorf("Content-Type = %q, want %q", got, "image/jpeg") + } + cfg, err := jpeg.DecodeConfig(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatalf("jpeg.DecodeConfig() err = %v, want nil", err) + } + if cfg.Width != 500 || cfg.Height != 409 { + t.Errorf("GET medium = %dx%d, want 500x409", cfg.Width, cfg.Height) + } +} + +func TestResizedCachesWhatItGenerated(t *testing.T) { + h, root := newResizedHandler(t, "image/jpeg") + + getResized(t, h, "/local--resized-images/probe/photo.jpg/small.jpg") + + cached := filepath.Join(root, "resized", "article-uuid", "file-uuid", "small.jpg") + if _, err := os.Stat(cached); err != nil { + t.Fatalf("Stat(%q) err = %v, want nil", cached, err) + } +} + +func TestResizedAnswersFromTheCacheWhenTheOriginalIsGone(t *testing.T) { + h, root := newResizedHandler(t, "image/jpeg") + getResized(t, h, "/local--resized-images/probe/photo.jpg/square.jpg") + + if err := os.Remove(filepath.Join(root, "media", "article-uuid", "file-uuid")); err != nil { + t.Fatalf("Remove() err = %v, want nil", err) + } + + w := getResized(t, h, "/local--resized-images/probe/photo.jpg/square.jpg") + if w.Code != http.StatusOK { + t.Errorf("GET square = %d, want %d", w.Code, http.StatusOK) + } +} + +func TestResizedRejectsTheSizeWikidotRejects(t *testing.T) { + h, _ := newResizedHandler(t, "image/jpeg") + + w := getResized(t, h, "/local--resized-images/probe/photo.jpg/large.jpg") + if w.Code != http.StatusNotFound { + t.Errorf("GET large = %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestResizedRejectsWhatIsNotAnImage(t *testing.T) { + h, _ := newResizedHandler(t, "audio/mpeg") + + w := getResized(t, h, "/local--resized-images/probe/photo.jpg/medium.jpg") + if w.Code != http.StatusNotFound { + t.Errorf("GET of an audio attachment = %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestResizedRejectsAMissingAttachment(t *testing.T) { + h, _ := newResizedHandler(t, "image/jpeg") + + w := getResized(t, h, "/local--resized-images/probe/nothing.jpg/medium.jpg") + if w.Code != http.StatusNotFound { + t.Errorf("GET of a missing attachment = %d, want %d", w.Code, http.StatusNotFound) + } +} + +func TestResizedRejectsAPathThatEscapes(t *testing.T) { + h, _ := newResizedHandler(t, "image/jpeg") + + w := getResized(t, h, "/local--resized-images/probe/photo.jpg/../../medium.jpg") + if w.Code != http.StatusNotFound { + t.Errorf("GET of an escaping path = %d, want %d", w.Code, http.StatusNotFound) + } +} diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 00000000..80935861 --- /dev/null +++ b/internal/migrate/migrate.go @@ -0,0 +1,403 @@ +// Package migrate owns the database schema. +package migrate + +import ( + "context" + "embed" + "fmt" + "path" + "slices" + "strings" + + "github.com/jackc/pgx/v5" + + "github.com/WikitTeam/ProjectWikit/internal/version" +) + +//go:embed sql/*.sql +var files embed.FS + +const ( + dir = "sql" + + BaselineName = "0001_baseline.sql" + + // The schema the baseline was taken from, as the database that wrote it + // records the name. + BaselineSchema = "0087_align_models_and_schema" + + lockKey = int64(0x7077696B_69746D67) + + versionTable = "pwikit_migration" + + declarationPrefix = "-- compat: " + compatible = "compatible" + breakingValue = "breaking" +) + +type State struct { + Applied []string + Pending []string + Adoptable bool + Unknown []string + UnknownBreaking []string + AppliedBy string +} + +func (s State) PendingBreaking() bool { + for _, name := range s.Pending { + if breaking[name] { + return true + } + } + return false +} + +type Result struct { + Adopted bool + Applied []string + Newer []string +} + +type NewerSchemaError struct { + Migrations []string + AppliedBy string +} + +func (e *NewerSchemaError) Error() string { + by := "a newer pwikit" + if e.AppliedBy != "" { + by = "pwikit " + e.AppliedBy + } + return fmt.Sprintf("the database was upgraded by %s, which applied %s; this pwikit (%s) cannot run on that schema. "+ + "Run %s or a newer release, or restore a backup taken before the upgrade", + by, strings.Join(e.Migrations, ", "), version.String(), by) +} + +var names, breaking, declarationErr = load() + +func Names() []string { return slices.Clone(names) } + +func Breaking(name string) bool { return breaking[name] } + +func Declarations() error { return declarationErr } + +func load() ([]string, map[string]bool, error) { + entries, err := files.ReadDir(dir) + if err != nil { + panic(err) + } + out := make([]string, 0, len(entries)) + marks := map[string]bool{} + var problems []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + out = append(out, e.Name()) + body, err := files.ReadFile(path.Join(dir, e.Name())) + if err != nil { + panic(err) + } + first, _, _ := strings.Cut(string(body), "\n") + switch strings.TrimSpace(first) { + case declarationPrefix + compatible: + case declarationPrefix + breakingValue: + marks[e.Name()] = true + default: + problems = append(problems, e.Name()) + } + } + slices.Sort(out) + if len(problems) > 0 { + return out, marks, fmt.Errorf("%s must open with %q or %q", + strings.Join(problems, ", "), declarationPrefix+compatible, declarationPrefix+breakingValue) + } + return out, marks, nil +} + +func Status(ctx context.Context, dsn string) (State, error) { + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return State{}, fmt.Errorf("connect to read the schema state: %w", err) + } + defer conn.Close(ctx) + return status(ctx, conn) +} + +func status(ctx context.Context, conn *pgx.Conn) (State, error) { + present, err := tableExists(ctx, conn, versionTable) + if err != nil { + return State{}, err + } + var applied []record + if present { + applied, err = appliedRecords(ctx, conn) + if err != nil { + return State{}, err + } + } + + state := State{} + for _, r := range applied { + state.Applied = append(state.Applied, r.name) + } + for _, name := range names { + if !slices.Contains(state.Applied, name) { + state.Pending = append(state.Pending, name) + } + } + for _, r := range applied { + if slices.Contains(names, r.name) { + continue + } + state.Unknown = append(state.Unknown, r.name) + if r.breaking { + state.UnknownBreaking = append(state.UnknownBreaking, r.name) + } + if r.appliedBy != "" { + state.AppliedBy = r.appliedBy + } + } + if len(applied) == 0 { + state.Adoptable, err = builtElsewhere(ctx, conn) + if err != nil { + return State{}, err + } + } + return state, nil +} + +func Run(ctx context.Context, dsn string) (Result, error) { + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return Result{}, fmt.Errorf("connect to migrate: %w", err) + } + defer conn.Close(ctx) + + if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", lockKey); err != nil { + return Result{}, fmt.Errorf("take the migration lock: %w", err) + } + defer conn.Exec(context.WithoutCancel(ctx), "SELECT pg_advisory_unlock($1)", lockKey) + + return run(ctx, conn) +} + +func run(ctx context.Context, conn *pgx.Conn) (Result, error) { + if declarationErr != nil { + return Result{}, declarationErr + } + if err := ensureLedger(ctx, conn); err != nil { + return Result{}, err + } + + state, err := status(ctx, conn) + if err != nil { + return Result{}, err + } + if len(state.UnknownBreaking) > 0 { + return Result{}, &NewerSchemaError{Migrations: state.UnknownBreaking, AppliedBy: state.AppliedBy} + } + var out Result + if len(state.Unknown) > 0 { + if len(state.Pending) > 0 { + return Result{}, fmt.Errorf("the database holds %s from a newer pwikit while this build still has %s to apply; run the newer pwikit", + strings.Join(state.Unknown, ", "), strings.Join(state.Pending, ", ")) + } + out.Newer = state.Unknown + } + + // Rows written before the ledger kept declarations carry the default, and + // an older build reading them later needs the truth. + for _, name := range state.Applied { + if breaking[name] { + if _, err := conn.Exec(ctx, `UPDATE `+versionTable+` SET breaking = true WHERE name = $1 AND NOT breaking`, name); err != nil { + return Result{}, fmt.Errorf("record the declaration of %s: %w", name, err) + } + } + } + + applied := state.Applied + if len(applied) == 0 { + adopt, err := builtElsewhere(ctx, conn) + if err != nil { + return Result{}, err + } + if adopt { + if _, err := conn.Exec(ctx, insertLedger, BaselineName, breaking[BaselineName], version.String()); err != nil { + return Result{}, fmt.Errorf("record %s: %w", BaselineName, err) + } + applied = append(applied, BaselineName) + out.Adopted = true + } + } + + for _, name := range names { + if slices.Contains(applied, name) { + continue + } + if err := apply(ctx, conn, name); err != nil { + return out, err + } + out.Applied = append(out.Applied, name) + } + return out, nil +} + +func ensureLedger(ctx context.Context, conn *pgx.Conn) error { + if _, err := conn.Exec(ctx, ledgerDDL); err != nil { + return fmt.Errorf("create %s: %w", versionTable, err) + } + declared, err := ledgerDeclares(ctx, conn) + if err != nil || declared { + return err + } + if _, err := conn.Exec(ctx, `ALTER TABLE `+versionTable+` + ADD COLUMN IF NOT EXISTS breaking boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS applied_by text NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add the declaration columns to %s: %w", versionTable, err) + } + return nil +} + +const ledgerDDL = `CREATE TABLE IF NOT EXISTS ` + versionTable + ` ( + name text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now(), + breaking boolean NOT NULL DEFAULT false, + applied_by text NOT NULL DEFAULT '')` + +// ApplyInto replays migrations inside a transaction the caller owns, which is +// what lets a restore rebuild the schema and load the data all or nothing. +func ApplyInto(ctx context.Context, tx pgx.Tx, wanted []string) error { + if declarationErr != nil { + return declarationErr + } + for _, name := range wanted { + if !slices.Contains(names, name) { + return fmt.Errorf("this build does not carry %q", name) + } + } + if _, err := tx.Exec(ctx, ledgerDDL); err != nil { + return fmt.Errorf("create %s: %w", versionTable, err) + } + for _, name := range wanted { + body, err := files.ReadFile(path.Join(dir, name)) + if err != nil { + return err + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + return fmt.Errorf("apply %s: %w", name, err) + } + if _, err := tx.Exec(ctx, insertLedger, name, breaking[name], version.String()); err != nil { + return fmt.Errorf("record %s: %w", name, err) + } + // A deferrable key made here leaves its first check queued, and a queued + // check blocks the next migration from altering that table. + if _, err := tx.Exec(ctx, `SET CONSTRAINTS ALL IMMEDIATE`); err != nil { + return fmt.Errorf("settle the constraints after %s: %w", name, err) + } + } + return nil +} + +const insertLedger = `INSERT INTO ` + versionTable + ` (name, breaking, applied_by) VALUES ($1, $2, $3)` + +func ledgerDeclares(ctx context.Context, conn *pgx.Conn) (bool, error) { + var columns int + if err := conn.QueryRow(ctx, ` +SELECT count(*) FROM information_schema.columns +WHERE table_schema = 'public' AND table_name = $1 AND column_name IN ('breaking', 'applied_by')`, versionTable).Scan(&columns); err != nil { + return false, fmt.Errorf("read the columns of %s: %w", versionTable, err) + } + return columns == 2, nil +} + +func apply(ctx context.Context, conn *pgx.Conn, name string) error { + body, err := files.ReadFile(path.Join(dir, name)) + if err != nil { + return err + } + tx, err := conn.Begin(ctx) + if err != nil { + return fmt.Errorf("begin %s: %w", name, err) + } + defer tx.Rollback(context.WithoutCancel(ctx)) + + if _, err := tx.Exec(ctx, string(body)); err != nil { + return fmt.Errorf("apply %s: %w", name, err) + } + if _, err := tx.Exec(ctx, insertLedger, name, breaking[name], version.String()); err != nil { + return fmt.Errorf("record %s: %w", name, err) + } + return tx.Commit(ctx) +} + +type record struct { + name string + breaking bool + appliedBy string +} + +func appliedRecords(ctx context.Context, conn *pgx.Conn) ([]record, error) { + declared, err := ledgerDeclares(ctx, conn) + if err != nil { + return nil, err + } + query := `SELECT name, false, '' FROM ` + versionTable + ` ORDER BY name` + if declared { + query = `SELECT name, breaking, applied_by FROM ` + versionTable + ` ORDER BY name` + } + rows, err := conn.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("read %s: %w", versionTable, err) + } + defer rows.Close() + + var out []record + for rows.Next() { + var r record + if err := rows.Scan(&r.name, &r.breaking, &r.appliedBy); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func builtElsewhere(ctx context.Context, conn *pgx.Conn) (bool, error) { + present, err := tableExists(ctx, conn, "django_migrations") + if err != nil || !present { + return false, err + } + var any bool + if err := conn.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM public.django_migrations)`).Scan(&any); err != nil { + return false, fmt.Errorf("read the previous version table: %w", err) + } + if !any { + return false, nil + } + return true, checkBaselineSchema(ctx, conn) +} + +// Adopting a database that stopped before the baseline was taken would apply +// every later migration to a schema they do not fit. +func checkBaselineSchema(ctx context.Context, conn *pgx.Conn) error { + var reached string + err := conn.QueryRow(ctx, ` +SELECT coalesce(max(name), '') FROM public.django_migrations WHERE app = 'web'`).Scan(&reached) + if err != nil { + return fmt.Errorf("read the previous version table: %w", err) + } + if reached == "" || reached >= BaselineSchema { + return nil + } + return fmt.Errorf("this database stopped at %q and pwikit needs the schema of %q, so nothing was changed", reached, BaselineSchema) +} + +func tableExists(ctx context.Context, conn *pgx.Conn, name string) (bool, error) { + var present bool + if err := conn.QueryRow(ctx, `SELECT to_regclass('public.' || $1) IS NOT NULL`, name).Scan(&present); err != nil { + return false, fmt.Errorf("look for table %q: %w", name, err) + } + return present, nil +} diff --git a/internal/migrate/migrate_test.go b/internal/migrate/migrate_test.go new file mode 100644 index 00000000..9f8fb47a --- /dev/null +++ b/internal/migrate/migrate_test.go @@ -0,0 +1,584 @@ +package migrate + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "os" + "slices" + "strings" + "testing" + + "github.com/jackc/pgx/v5" + + "github.com/WikitTeam/ProjectWikit/internal/perms" +) + +const envDSN = "PWIKIT_TEST_DSN" + +func TestNamesAreOrderedAndStartAtTheBaseline(t *testing.T) { + got := Names() + if len(got) == 0 { + t.Fatal("Names() = [], want at least the baseline") + } + if got[0] != BaselineName { + t.Errorf("Names()[0] = %q, want %q", got[0], BaselineName) + } + if !slices.IsSorted(got) { + t.Errorf("Names() = %v, want it sorted", got) + } +} + +func TestBaselineReproducesTheSchemaItWasTakenFrom(t *testing.T) { + reference := requireDSN(t) + fresh := scratch(t) + + result, err := Run(context.Background(), fresh) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if result.Adopted { + t.Error("Run().Adopted = true, want false on an empty database") + } + if !slices.Equal(result.Applied, Names()) { + t.Errorf("Run().Applied = %v, want %v", result.Applied, Names()) + } + + for _, part := range []struct { + name string + sql string + }{ + {"columns", qColumns}, + {"indexes", qIndexes}, + {"constraints", qConstraints}, + } { + want := describe(t, reference, part.sql) + got := describe(t, fresh, part.sql) + diffLines(t, part.name, got, want) + } +} + +func TestRunAppliesNothingTwice(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + again, err := Run(ctx, fresh) + if err != nil { + t.Fatalf("Run() second time err = %v, want nil", err) + } + if len(again.Applied) != 0 { + t.Errorf("Run() second time Applied = %v, want []", again.Applied) + } + if again.Adopted { + t.Error("Run() second time Adopted = true, want false") + } +} + +func TestRunAdoptsASchemaBuiltBeforeGoOwnedIt(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + conn := connect(t, fresh) + body, err := files.ReadFile(dir + "/" + BaselineName) + if err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, string(body)); err != nil { + t.Fatalf("apply the baseline by hand err = %v, want nil", err) + } + if _, err := conn.Exec(ctx, + `INSERT INTO django_migrations (app, name, applied) VALUES ('web', '`+BaselineSchema+`', now())`); err != nil { + t.Fatalf("record a previous migration err = %v, want nil", err) + } + + state, err := Status(ctx, fresh) + if err != nil { + t.Fatalf("Status() err = %v, want nil", err) + } + if !state.Adoptable { + t.Error("Status().Adoptable = false, want true") + } + if len(state.Applied) != 0 { + t.Errorf("Status().Applied = %v, want []", state.Applied) + } + + result, err := Run(ctx, fresh) + if err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if !result.Adopted { + t.Error("Run().Adopted = false, want true") + } + if slices.Contains(result.Applied, BaselineName) { + t.Errorf("Run().Applied = %v, want it without %s because those tables are already there", + result.Applied, BaselineName) + } + if len(result.Applied) != len(Names())-1 { + t.Errorf("len(Run().Applied) = %d, want %d", len(result.Applied), len(Names())-1) + } +} + +func TestEveryMigrationDeclaresCompatibility(t *testing.T) { + if err := Declarations(); err != nil { + t.Errorf("Declarations() = %v, want nil", err) + } +} + +func TestRunRefusesABreakingSchemaNewerThanTheBinary(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + conn := connect(t, fresh) + if _, err := conn.Exec(ctx, + `INSERT INTO `+versionTable+` (name, breaking, applied_by) VALUES ('9999_from_the_future.sql', true, 'v9.0.0')`); err != nil { + t.Fatal(err) + } + + _, err := Run(ctx, fresh) + var newer *NewerSchemaError + if !errors.As(err, &newer) { + t.Fatalf("Run() over a newer breaking schema err = %v, want a NewerSchemaError", err) + } + if !slices.Equal(newer.Migrations, []string{"9999_from_the_future.sql"}) { + t.Errorf("NewerSchemaError.Migrations = %v, want [9999_from_the_future.sql]", newer.Migrations) + } + if newer.AppliedBy != "v9.0.0" { + t.Errorf("NewerSchemaError.AppliedBy = %q, want %q", newer.AppliedBy, "v9.0.0") + } + + state, err := Status(ctx, fresh) + if err != nil { + t.Fatalf("Status() err = %v, want nil", err) + } + if !slices.Equal(state.UnknownBreaking, []string{"9999_from_the_future.sql"}) { + t.Errorf("Status().UnknownBreaking = %v, want [9999_from_the_future.sql]", state.UnknownBreaking) + } +} + +func TestRunRunsAlongsideACompatibleNewerMigration(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + conn := connect(t, fresh) + if _, err := conn.Exec(ctx, + `INSERT INTO `+versionTable+` (name, breaking, applied_by) VALUES ('9999_from_the_future.sql', false, 'v9.0.0')`); err != nil { + t.Fatal(err) + } + + result, err := Run(ctx, fresh) + if err != nil { + t.Fatalf("Run() over a newer compatible schema err = %v, want nil", err) + } + if !slices.Equal(result.Newer, []string{"9999_from_the_future.sql"}) { + t.Errorf("Run().Newer = %v, want [9999_from_the_future.sql]", result.Newer) + } +} + +func TestRunRefusesNewerMigrationsWhileItsOwnArePending(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + last := Names()[len(Names())-1] + conn := connect(t, fresh) + if _, err := conn.Exec(ctx, `DELETE FROM `+versionTable+` WHERE name = $1`, last); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, + `INSERT INTO `+versionTable+` (name, breaking) VALUES ('9999_from_the_future.sql', false)`); err != nil { + t.Fatal(err) + } + + if _, err := Run(ctx, fresh); err == nil || !strings.Contains(err.Error(), last) { + t.Errorf("Run() with its own migration pending err = %v, want one naming %s", err, last) + } +} + +func TestRunRecordsDeclarationsInAnOlderLedger(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + conn := connect(t, fresh) + if _, err := conn.Exec(ctx, `CREATE TABLE `+versionTable+` (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { + t.Fatal(err) + } + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + if _, err := conn.Exec(ctx, `UPDATE `+versionTable+` SET breaking = false`); err != nil { + t.Fatal(err) + } + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() second time err = %v, want nil", err) + } + + for _, name := range Names() { + var got bool + if err := conn.QueryRow(ctx, `SELECT breaking FROM `+versionTable+` WHERE name = $1`, name).Scan(&got); err != nil { + t.Fatal(err) + } + if got != Breaking(name) { + t.Errorf("ledger breaking for %s = %t, want %t", name, got, Breaking(name)) + } + } +} + +func TestStatusWritesNothing(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + state, err := Status(ctx, fresh) + if err != nil { + t.Fatalf("Status() err = %v, want nil", err) + } + if !slices.Equal(state.Pending, Names()) { + t.Errorf("Status().Pending = %v, want %v", state.Pending, Names()) + } + + conn := connect(t, fresh) + var tables int + if err := conn.QueryRow(ctx, + `SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'`).Scan(&tables); err != nil { + t.Fatal(err) + } + if tables != 0 { + t.Errorf("tables after Status() = %d, want 0", tables) + } +} + +const qColumns = ` +SELECT table_name || ' ' || column_name || ' ' || data_type || + ' null=' || is_nullable || + ' len=' || coalesce(character_maximum_length::text, '-') || + ' default=' || coalesce(column_default, '-') +FROM information_schema.columns +WHERE table_schema = 'public' +ORDER BY 1` + +const qIndexes = ` +SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY 1` + +const qConstraints = ` +SELECT rel.relname || ' ' || con.conname || ' ' || pg_get_constraintdef(con.oid) +FROM pg_constraint con +JOIN pg_class rel ON rel.oid = con.conrelid +JOIN pg_namespace ns ON ns.oid = rel.relnamespace +WHERE ns.nspname = 'public' +ORDER BY 1` + +func describe(t *testing.T, dsn, query string) []string { + t.Helper() + conn := connect(t, dsn) + rows, err := conn.Query(context.Background(), query) + if err != nil { + t.Fatalf("Query() err = %v, want nil", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var line string + if err := rows.Scan(&line); err != nil { + t.Fatal(err) + } + if strings.Contains(line, versionTable) { + continue + } + out = append(out, line) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func diffLines(t *testing.T, part string, got, want []string) { + t.Helper() + if len(want) == 0 { + t.Fatalf("%s in the reference database = 0 rows, want the schema", part) + } + for _, line := range want { + if !slices.Contains(got, line) { + t.Errorf("%s missing after the baseline, want %q", part, line) + } + } + for _, line := range got { + if !slices.Contains(want, line) { + t.Errorf("%s added by the baseline, want it absent %q", part, line) + } + } +} + +func requireDSN(t *testing.T) string { + t.Helper() + dsn := os.Getenv(envDSN) + if dsn == "" { + t.Skipf("%s not set, skipping the database test", envDSN) + } + return dsn +} + +func connect(t *testing.T, dsn string) *pgx.Conn { + t.Helper() + conn, err := pgx.Connect(context.Background(), dsn) + if err != nil { + t.Fatalf("Connect() err = %v, want nil", err) + } + t.Cleanup(func() { conn.Close(context.Background()) }) + return conn +} + +func scratch(t *testing.T) string { + t.Helper() + dsn := requireDSN(t) + cfg, err := pgx.ParseConfig(dsn) + if err != nil { + t.Fatalf("ParseConfig() err = %v, want nil", err) + } + name := fmt.Sprintf("pwikit_migrate_%d", rand.Uint32()) + + admin := swapDatabase(t, dsn, "postgres") + ctx := context.Background() + control, err := pgx.Connect(ctx, admin) + if err != nil { + t.Skipf("cannot reach the maintenance database to make a scratch one: %v", err) + } + defer control.Close(ctx) + + if _, err := control.Exec(ctx, `CREATE DATABASE `+pgx.Identifier{name}.Sanitize()); err != nil { + t.Fatalf("CREATE DATABASE err = %v, want nil", err) + } + t.Cleanup(func() { + clean, err := pgx.Connect(context.Background(), admin) + if err != nil { + return + } + defer clean.Close(context.Background()) + clean.Exec(context.Background(), + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1`, name) + clean.Exec(context.Background(), `DROP DATABASE IF EXISTS `+pgx.Identifier{name}.Sanitize()) + }) + + cfg.Database = name + return swapDatabase(t, dsn, name) +} + +func swapDatabase(t *testing.T, dsn, name string) string { + t.Helper() + cut := strings.LastIndex(dsn, "/") + if cut < 0 { + t.Fatalf("no database in %q, want a URL style connection string", dsn) + } + rest := "" + if q := strings.Index(dsn[cut:], "?"); q >= 0 { + rest = dsn[cut+q:] + } + return dsn[:cut+1] + name + rest +} + +var catalog = []string{ + perms.ViewArticles, perms.RateArticles, perms.CreateArticles, perms.EditArticles, + perms.TagArticles, perms.MoveArticles, perms.LockArticles, perms.ManageArticleFiles, + perms.DeleteArticles, perms.ResetArticleVotes, perms.CommentArticles, + perms.ViewArticleComments, perms.ManageArticleAuthors, + perms.ViewForumPosts, perms.CreateForumPosts, perms.EditForumPosts, perms.DeleteForumPosts, + perms.ViewForumThreads, perms.CreateForumThreads, perms.EditForumThreads, + perms.PinForumThreads, perms.LockForumThreads, perms.MoveForumThreads, + perms.ViewForumSections, perms.ViewHiddenForumSections, perms.ViewForumCategories, + perms.ViewVotesTimestamp, perms.SendDirectMessage, perms.ViewUserReports, + perms.ViewReportedFullConversation, perms.ViewSensitiveInfo, perms.ManageUsers, +} + +func TestBaselineCarriesThePermissionCatalog(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + conn := connect(t, fresh) + + for _, codename := range catalog { + var found bool + if err := conn.QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM auth_permission WHERE codename = $1)`, codename).Scan(&found); err != nil { + t.Fatal(err) + } + if !found { + t.Errorf("auth_permission has %q = false, want true", codename) + } + } +} + +func TestBaselineCarriesTheBuiltInRoles(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + conn := connect(t, fresh) + + for _, c := range []struct { + table string + want int + }{ + {"web_rolecategory", 1}, + {"web_role", 4}, + {"web_role_permissions", 17}, + {"web_theme", 1}, + } { + if got := count(t, conn, c.table); got != c.want { + t.Errorf("count(%s) = %d, want %d", c.table, got, c.want) + } + } + + for _, table := range []string{"web_site", "web_settings", "web_category", "web_user", "django_migrations"} { + if got := count(t, conn, table); got != 0 { + t.Errorf("count(%s) = %d, want 0", table, got) + } + } +} + +func TestBaselineLeavesTheSequencesUsable(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + conn := connect(t, fresh) + + before := count(t, conn, "django_content_type") + var id int + if err := conn.QueryRow(ctx, + `INSERT INTO django_content_type (app_label, model) VALUES ('probe', 'probe') RETURNING id`).Scan(&id); err != nil { + t.Fatalf("insert without an id err = %v, want nil", err) + } + if id <= before { + t.Errorf("assigned id = %d, want it past the %d seeded rows", id, before) + } +} + +func count(t *testing.T, conn *pgx.Conn, table string) int { + t.Helper() + var n int + if err := conn.QueryRow(context.Background(), `SELECT count(*) FROM `+table).Scan(&n); err != nil { + t.Fatalf("count(%s) err = %v, want nil", table, err) + } + return n +} + +var carriedElsewhere = []string{ + "django_content_type", "auth_permission", "web_rolecategory", "web_role", + "web_role_permissions", "web_theme", "django_migrations", "django_session", + "django_admin_log", versionTable, +} + +func TestFixtureRebuildsTheTestDatabase(t *testing.T) { + reference := requireDSN(t) + fresh := scratch(t) + ctx := context.Background() + + if _, err := Run(ctx, fresh); err != nil { + t.Fatalf("Run() err = %v, want nil", err) + } + body, err := os.ReadFile("testdata/fixture.sql") + if err != nil { + t.Fatal(err) + } + conn := connect(t, fresh) + tx, err := conn.Begin(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + t.Fatalf("apply the fixture err = %v, want nil", err) + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + + want := rowCounts(t, reference) + got := rowCounts(t, fresh) + if len(want) == 0 { + t.Fatal("rowCounts(reference) = 0 tables, want the schema") + } + for table, n := range want { + if got[table] != n { + t.Errorf("count(%s) = %d, want %d", table, got[table], n) + } + } +} + +func rowCounts(t *testing.T, dsn string) map[string]int { + t.Helper() + conn := connect(t, dsn) + ctx := context.Background() + rows, err := conn.Query(ctx, + `SELECT table_name FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY 1`) + if err != nil { + t.Fatal(err) + } + var tables []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + if !slices.Contains(carriedElsewhere, name) { + tables = append(tables, name) + } + } + rows.Close() + if err := rows.Err(); err != nil { + t.Fatal(err) + } + + out := make(map[string]int, len(tables)) + for _, table := range tables { + out[table] = count(t, conn, table) + } + return out +} + +func TestRunRefusesASchemaOlderThanTheBaseline(t *testing.T) { + fresh := scratch(t) + ctx := context.Background() + + conn := connect(t, fresh) + if _, err := conn.Exec(ctx, ` +CREATE TABLE django_migrations (id bigserial PRIMARY KEY, app text NOT NULL, name text NOT NULL, applied timestamptz NOT NULL)`); err != nil { + t.Fatalf("create the previous version table err = %v, want nil", err) + } + if _, err := conn.Exec(ctx, + `INSERT INTO django_migrations (app, name, applied) VALUES ('web', '0079_theme_slug', now())`); err != nil { + t.Fatalf("record a previous migration err = %v, want nil", err) + } + + _, err := Run(ctx, fresh) + if err == nil { + t.Fatal("Run(a database older than the baseline) err = nil, want non-nil") + } + if !strings.Contains(err.Error(), "0079_theme_slug") || !strings.Contains(err.Error(), BaselineSchema) { + t.Errorf("Run() err = %v, want it to name both schemas", err) + } + var tables int + if err := conn.QueryRow(ctx, ` +SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name LIKE 'web_%'`).Scan(&tables); err != nil { + t.Fatal(err) + } + if tables != 0 { + t.Errorf("tables after the refusal = %d, want 0", tables) + } +} diff --git a/internal/migrate/sql/0001_baseline.sql b/internal/migrate/sql/0001_baseline.sql new file mode 100644 index 00000000..ff4a192b --- /dev/null +++ b/internal/migrate/sql/0001_baseline.sql @@ -0,0 +1,3238 @@ +-- compat: breaking +CREATE EXTENSION IF NOT EXISTS citext WITH SCHEMA public; + +CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public; + +CREATE TABLE public.auth_group ( + id integer NOT NULL, + name character varying(150) NOT NULL +); + +ALTER TABLE public.auth_group ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.auth_group_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.auth_group_permissions ( + id bigint NOT NULL, + group_id integer NOT NULL, + permission_id integer NOT NULL +); + +ALTER TABLE public.auth_group_permissions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.auth_group_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.auth_permission ( + id integer NOT NULL, + name character varying(255) NOT NULL, + content_type_id integer NOT NULL, + codename character varying(100) NOT NULL +); + +ALTER TABLE public.auth_permission ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.auth_permission_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.django_admin_log ( + id integer NOT NULL, + action_time timestamp with time zone NOT NULL, + object_id text, + object_repr character varying(200) NOT NULL, + action_flag smallint NOT NULL, + change_message text NOT NULL, + content_type_id integer, + user_id bigint NOT NULL, + CONSTRAINT django_admin_log_action_flag_check CHECK ((action_flag >= 0)) +); + +ALTER TABLE public.django_admin_log ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.django_admin_log_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.django_content_type ( + id integer NOT NULL, + app_label character varying(100) NOT NULL, + model character varying(100) NOT NULL +); + +ALTER TABLE public.django_content_type ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.django_content_type_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.django_migrations ( + id bigint NOT NULL, + app character varying(255) NOT NULL, + name character varying(255) NOT NULL, + applied timestamp with time zone NOT NULL +); + +ALTER TABLE public.django_migrations ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.django_migrations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.django_session ( + session_key character varying(40) NOT NULL, + session_data text NOT NULL, + expire_date timestamp with time zone NOT NULL +); + +CREATE TABLE public.dynamic_preferences_globalpreferencemodel ( + id integer NOT NULL, + section character varying(150), + name character varying(150) NOT NULL, + raw_value text +); + +ALTER TABLE public.dynamic_preferences_globalpreferencemodel ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.dynamic_preferences_globalpreferencemodel_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.dynamic_preferences_users_userpreferencemodel ( + id integer NOT NULL, + section character varying(150), + name character varying(150) NOT NULL, + raw_value text, + instance_id bigint NOT NULL +); + +ALTER TABLE public.dynamic_preferences_users_userpreferencemodel ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.dynamic_preferences_users_userpreferencemodel_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_actionlogentry ( + id bigint NOT NULL, + stale_username text NOT NULL, + type text NOT NULL, + meta jsonb NOT NULL, + created_at timestamp with time zone NOT NULL, + origin_ip inet, + user_id bigint NOT NULL +); + +ALTER TABLE public.web_actionlogentry ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_actionlogentry_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_article ( + id bigint NOT NULL, + category public.citext NOT NULL, + name public.citext NOT NULL, + title text NOT NULL, + locked boolean NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + parent_id bigint, + media_name text NOT NULL, + complete_full_name public.citext GENERATED ALWAYS AS (((COALESCE(category, ''::public.citext))::text || COALESCE((COALESCE(':'::text, ''::text) || (COALESCE(name, ''::public.citext))::text), ''::text))) STORED +); + +CREATE TABLE public.web_article_authors ( + id bigint NOT NULL, + article_id bigint NOT NULL, + user_id bigint NOT NULL +); + +ALTER TABLE public.web_article_authors ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_article_authors_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE public.web_article ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_article_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_article_tags ( + id bigint NOT NULL, + article_id bigint NOT NULL, + tag_id bigint NOT NULL +); + +ALTER TABLE public.web_article_tags ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_article_tags_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_articlefavourite ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + article_id bigint NOT NULL, + user_id bigint NOT NULL +); + +ALTER TABLE public.web_articlefavourite ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_articlefavourite_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_articlelogentry ( + id bigint NOT NULL, + type text NOT NULL, + meta jsonb NOT NULL, + created_at timestamp with time zone NOT NULL, + comment text NOT NULL, + rev_number integer NOT NULL, + article_id bigint NOT NULL, + user_id bigint, + CONSTRAINT web_articlelogentry_rev_number_check CHECK ((rev_number >= 0)) +); + +ALTER TABLE public.web_articlelogentry ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_articlelogentry_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_articlesearchindex ( + id bigint NOT NULL, + content_plaintext text NOT NULL, + content_source text NOT NULL, + vector_plaintext tsvector, + article_id bigint +); + +ALTER TABLE public.web_articlesearchindex ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_articlesearchindex_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_articleversion ( + id bigint NOT NULL, + source text NOT NULL, + rendered text, + created_at timestamp with time zone NOT NULL, + article_id bigint NOT NULL, + ast jsonb +); + +ALTER TABLE public.web_articleversion ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_articleversion_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_category ( + id bigint NOT NULL, + name public.citext NOT NULL, + is_indexed boolean NOT NULL +); + +ALTER TABLE public.web_category ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_category_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_category_permissions_override ( + id bigint NOT NULL, + category_id bigint NOT NULL, + rolepermissionsoverride_id bigint NOT NULL +); + +ALTER TABLE public.web_category_permissions_override ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_category_permissions_override_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_directmessage ( + id bigint NOT NULL, + body text NOT NULL, + created_at timestamp with time zone NOT NULL, + is_read boolean NOT NULL, + recipient_id bigint NOT NULL, + sender_id bigint NOT NULL +); + +ALTER TABLE public.web_directmessage ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_directmessage_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_directmessageblock ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + blocked_id bigint NOT NULL, + blocker_id bigint NOT NULL +); + +ALTER TABLE public.web_directmessageblock ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_directmessageblock_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_externallink ( + id bigint NOT NULL, + link_from public.citext NOT NULL, + link_to public.citext NOT NULL, + link_type text NOT NULL +); + +ALTER TABLE public.web_externallink ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_externallink_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_file ( + id bigint NOT NULL, + name text NOT NULL, + media_name text NOT NULL, + mime_type text NOT NULL, + size bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone, + article_id bigint NOT NULL, + author_id bigint, + deleted_by_id bigint, + CONSTRAINT web_file_size_check CHECK ((size >= 0)) +); + +ALTER TABLE public.web_file ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_file_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_forumcategory ( + id bigint NOT NULL, + name text NOT NULL, + description text NOT NULL, + "order" integer NOT NULL, + is_for_comments boolean NOT NULL, + section_id bigint NOT NULL +); + +ALTER TABLE public.web_forumcategory ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_forumcategory_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_forumpost ( + id bigint NOT NULL, + name text NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + author_id bigint, + reply_to_id bigint, + thread_id bigint NOT NULL +); + +ALTER TABLE public.web_forumpost ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_forumpost_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_forumpostlike ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + post_id bigint NOT NULL, + user_id bigint NOT NULL +); + +ALTER TABLE public.web_forumpostlike ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_forumpostlike_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_forumpostversion ( + id bigint NOT NULL, + source text NOT NULL, + post_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + author_id bigint +); + +ALTER TABLE public.web_forumpostversion ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_forumpostversion_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_forumsection ( + id bigint NOT NULL, + name text NOT NULL, + description text NOT NULL, + "order" integer NOT NULL, + is_hidden boolean NOT NULL, + is_hidden_for_users boolean NOT NULL +); + +ALTER TABLE public.web_forumsection ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_forumsection_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_forumthread ( + id bigint NOT NULL, + name text NOT NULL, + description text NOT NULL, + article_id bigint, + author_id bigint, + category_id bigint, + is_pinned boolean NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + is_locked boolean NOT NULL, + CONSTRAINT web_forumthread_category_or_article CHECK ((num_nonnulls(article_id, category_id) = 1)) +); + +ALTER TABLE public.web_forumthread ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_forumthread_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_invitelink ( + id bigint NOT NULL, + kind text NOT NULL, + delivery text NOT NULL, + email text NOT NULL, + wikidot_username text NOT NULL, + token text NOT NULL, + uidb64 text NOT NULL, + created_at timestamp with time zone NOT NULL, + activated_at timestamp with time zone, + activated_username text NOT NULL, + created_by_id bigint, + target_id bigint +); + +ALTER TABLE public.web_invitelink ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_invitelink_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_role ( + id bigint NOT NULL, + slug character varying NOT NULL, + name character varying NOT NULL, + short_name character varying NOT NULL, + index integer NOT NULL, + is_staff boolean NOT NULL, + group_votes boolean NOT NULL, + votes_title character varying NOT NULL, + inline_visual_mode character varying NOT NULL, + profile_visual_mode character varying NOT NULL, + color character varying NOT NULL, + icon character varying(100) NOT NULL, + badge_text character varying NOT NULL, + badge_bg character varying NOT NULL, + badge_text_color character varying NOT NULL, + badge_show_border boolean NOT NULL, + category_id bigint, + CONSTRAINT web_role_index_check CHECK ((index >= 0)) +); + +ALTER TABLE public.web_role ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_role_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_role_permissions ( + id bigint NOT NULL, + role_id bigint NOT NULL, + permission_id integer NOT NULL +); + +ALTER TABLE public.web_role_permissions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_role_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_role_restrictions ( + id bigint NOT NULL, + role_id bigint NOT NULL, + permission_id integer NOT NULL +); + +ALTER TABLE public.web_role_restrictions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_role_restrictions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_rolecategory ( + id bigint NOT NULL, + name character varying NOT NULL +); + +ALTER TABLE public.web_rolecategory ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_rolecategory_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_rolepermissionsoverride ( + id bigint NOT NULL, + role_id bigint NOT NULL +); + +ALTER TABLE public.web_rolepermissionsoverride ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_rolepermissionsoverride_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_rolepermissionsoverride_permissions ( + id bigint NOT NULL, + rolepermissionsoverride_id bigint NOT NULL, + permission_id integer NOT NULL +); + +ALTER TABLE public.web_rolepermissionsoverride_permissions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_rolepermissionsoverride_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_rolepermissionsoverride_restrictions ( + id bigint NOT NULL, + rolepermissionsoverride_id bigint NOT NULL, + permission_id integer NOT NULL +); + +ALTER TABLE public.web_rolepermissionsoverride_restrictions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_rolepermissionsoverride_restrictions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_settings ( + id bigint NOT NULL, + rating_mode text NOT NULL, + category_id bigint, + site_id bigint, + can_user_create_tags text NOT NULL +); + +ALTER TABLE public.web_settings ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_settings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_site ( + id bigint NOT NULL, + slug text NOT NULL, + title text NOT NULL, + headline text NOT NULL, + icon character varying(100), + domain text NOT NULL, + media_domain text NOT NULL, + home_page text NOT NULL, + active_theme_id bigint, + auth_icon character varying(100), + default_role_id bigint, + footer_license text NOT NULL, + membership_password text NOT NULL, + membership_password_enabled boolean NOT NULL, + membership_password_role_id bigint, + signup_notice text NOT NULL, + verified_role_id bigint, + system_theme_id bigint, + password_help text NOT NULL, + email_policy text NOT NULL +); + +ALTER TABLE public.web_site ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_site_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_tag ( + id bigint NOT NULL, + name text NOT NULL, + category_id bigint NOT NULL +); + +ALTER TABLE public.web_tag ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_tag_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_tagscategory ( + id bigint NOT NULL, + name text NOT NULL, + description text NOT NULL, + priority integer, + slug text NOT NULL, + CONSTRAINT web_tagscategory_priority_check CHECK ((priority >= 0)) +); + +ALTER TABLE public.web_tagscategory ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_tagscategory_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_theme ( + id bigint NOT NULL, + name text NOT NULL, + mode text NOT NULL, + css text NOT NULL, + external_url text NOT NULL, + updated_at timestamp with time zone NOT NULL, + slug text NOT NULL +); + +ALTER TABLE public.web_theme ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_theme_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_usedtoken ( + id bigint NOT NULL, + token text NOT NULL, + is_case_sensitive boolean NOT NULL +); + +ALTER TABLE public.web_usedtoken ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_usedtoken_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_user ( + id bigint NOT NULL, + password character varying(128) NOT NULL, + last_login timestamp with time zone, + is_superuser boolean NOT NULL, + first_name character varying(150) NOT NULL, + last_name character varying(150) NOT NULL, + email character varying(254) NOT NULL, + date_joined timestamp with time zone NOT NULL, + username public.citext NOT NULL, + wikidot_username public.citext, + type text NOT NULL, + avatar character varying(100), + bio text NOT NULL, + api_key character varying(255), + is_forum_active boolean NOT NULL, + forum_inactive_until timestamp with time zone, + is_active boolean NOT NULL, + inactive_until timestamp with time zone, + can_send_direct_messages boolean NOT NULL, + display_name character varying(150), + email_verified_at timestamp with time zone, + pending_email text NOT NULL, + previous_email text NOT NULL, + email_changed_at timestamp with time zone, + username_changed_at timestamp with time zone +); + +CREATE TABLE public.web_user_groups ( + id bigint NOT NULL, + user_id bigint NOT NULL, + group_id integer NOT NULL +); + +ALTER TABLE public.web_user_groups ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_user_groups_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE public.web_user ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_user_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_user_roles ( + id bigint NOT NULL, + user_id bigint NOT NULL, + role_id bigint NOT NULL +); + +ALTER TABLE public.web_user_roles ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_user_roles_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_user_user_permissions ( + id bigint NOT NULL, + user_id bigint NOT NULL, + permission_id integer NOT NULL +); + +ALTER TABLE public.web_user_user_permissions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_user_user_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_usernotification ( + id bigint NOT NULL, + type text NOT NULL, + meta jsonb NOT NULL, + created_at timestamp with time zone NOT NULL +); + +ALTER TABLE public.web_usernotification ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_usernotification_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_usernotificationmapping ( + id bigint NOT NULL, + is_viewed boolean NOT NULL, + notification_id bigint NOT NULL, + recipient_id bigint NOT NULL +); + +ALTER TABLE public.web_usernotificationmapping ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_usernotificationmapping_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_usernotificationsubscription ( + id bigint NOT NULL, + article_id bigint, + forum_thread_id bigint, + subscriber_id bigint NOT NULL +); + +ALTER TABLE public.web_usernotificationsubscription ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_usernotificationsubscription_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_userreport ( + id bigint NOT NULL, + reason text NOT NULL, + reported_messages jsonb NOT NULL, + status text NOT NULL, + admin_notes text NOT NULL, + created_at timestamp with time zone NOT NULL, + reviewed_at timestamp with time zone, + reported_id bigint, + reporter_id bigint, + reviewed_by_id bigint +); + +ALTER TABLE public.web_userreport ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_userreport_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_userticket ( + id bigint NOT NULL, + kind text NOT NULL, + subject text NOT NULL, + body text NOT NULL, + source_page text NOT NULL, + status text NOT NULL, + admin_notes text NOT NULL, + created_at timestamp with time zone NOT NULL, + reviewed_at timestamp with time zone, + author_id bigint, + granted_role_id bigint, + reviewed_by_id bigint +); + +ALTER TABLE public.web_userticket ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_userticket_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +CREATE TABLE public.web_vote ( + id bigint NOT NULL, + rate double precision NOT NULL, + article_id bigint NOT NULL, + user_id bigint, + date timestamp with time zone, + role_id bigint +); + +ALTER TABLE public.web_vote ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.web_vote_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.auth_group + ADD CONSTRAINT auth_group_name_key UNIQUE (name); + +ALTER TABLE ONLY public.auth_group_permissions + ADD CONSTRAINT auth_group_permissions_group_id_permission_id_0cd325b0_uniq UNIQUE (group_id, permission_id); + +ALTER TABLE ONLY public.auth_group_permissions + ADD CONSTRAINT auth_group_permissions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.auth_group + ADD CONSTRAINT auth_group_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.auth_permission + ADD CONSTRAINT auth_permission_content_type_id_codename_01ab375a_uniq UNIQUE (content_type_id, codename); + +ALTER TABLE ONLY public.auth_permission + ADD CONSTRAINT auth_permission_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.django_admin_log + ADD CONSTRAINT django_admin_log_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.django_content_type + ADD CONSTRAINT django_content_type_app_label_model_76bd3d3b_uniq UNIQUE (app_label, model); + +ALTER TABLE ONLY public.django_content_type + ADD CONSTRAINT django_content_type_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.django_migrations + ADD CONSTRAINT django_migrations_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.django_session + ADD CONSTRAINT django_session_pkey PRIMARY KEY (session_key); + +ALTER TABLE ONLY public.web_directmessageblock + ADD CONSTRAINT dm_block_uniqueness UNIQUE (blocker_id, blocked_id); + +ALTER TABLE ONLY public.dynamic_preferences_globalpreferencemodel + ADD CONSTRAINT dynamic_preferences_glob_section_name_f4a2439b_uniq UNIQUE (section, name); + +ALTER TABLE ONLY public.dynamic_preferences_globalpreferencemodel + ADD CONSTRAINT dynamic_preferences_globalpreferencemodel_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.dynamic_preferences_users_userpreferencemodel + ADD CONSTRAINT dynamic_preferences_user_instance_id_section_name_29814e3f_uniq UNIQUE (instance_id, section, name); + +ALTER TABLE ONLY public.dynamic_preferences_users_userpreferencemodel + ADD CONSTRAINT dynamic_preferences_users_userpreferencemodel_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_actionlogentry + ADD CONSTRAINT web_actionlogentry_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_article_authors + ADD CONSTRAINT web_article_authors_article_id_user_id_1f42e1c5_uniq UNIQUE (article_id, user_id); + +ALTER TABLE ONLY public.web_article_authors + ADD CONSTRAINT web_article_authors_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_article + ADD CONSTRAINT web_article_media_name_6fe9ffb3_uniq UNIQUE (media_name); + +ALTER TABLE ONLY public.web_article + ADD CONSTRAINT web_article_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_article_tags + ADD CONSTRAINT web_article_tags_article_id_tag_id_13d5437b_uniq UNIQUE (article_id, tag_id); + +ALTER TABLE ONLY public.web_article_tags + ADD CONSTRAINT web_article_tags_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_article + ADD CONSTRAINT web_article_unique UNIQUE (category, name); + +ALTER TABLE ONLY public.web_articlefavourite + ADD CONSTRAINT web_articlefavourite_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_articlefavourite + ADD CONSTRAINT web_articlefavourite_unique UNIQUE (article_id, user_id); + +ALTER TABLE ONLY public.web_articlelogentry + ADD CONSTRAINT web_articlelogentry_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_articlelogentry + ADD CONSTRAINT web_articlelogentry_unique UNIQUE (article_id, rev_number); + +ALTER TABLE ONLY public.web_articlesearchindex + ADD CONSTRAINT web_articlesearchindex_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_articlesearchindex + ADD CONSTRAINT web_articlesearchindex_unique UNIQUE (article_id); + +ALTER TABLE ONLY public.web_articleversion + ADD CONSTRAINT web_articleversion_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_category_permissions_override + ADD CONSTRAINT web_category_permissions_category_id_rolepermissi_d6aeb1ec_uniq UNIQUE (category_id, rolepermissionsoverride_id); + +ALTER TABLE ONLY public.web_category_permissions_override + ADD CONSTRAINT web_category_permissions_override_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_category + ADD CONSTRAINT web_category_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_category + ADD CONSTRAINT web_category_unique UNIQUE (name); + +ALTER TABLE ONLY public.web_directmessage + ADD CONSTRAINT web_directmessage_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_directmessageblock + ADD CONSTRAINT web_directmessageblock_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_externallink + ADD CONSTRAINT web_externallink_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_externallink + ADD CONSTRAINT web_externallink_unique UNIQUE (link_from, link_to, link_type); + +ALTER TABLE ONLY public.web_file + ADD CONSTRAINT web_file_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_file + ADD CONSTRAINT web_file_unique UNIQUE (article_id, name, deleted_at); + +ALTER TABLE ONLY public.web_forumcategory + ADD CONSTRAINT web_forumcategory_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_forumpost + ADD CONSTRAINT web_forumpost_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_forumpostlike + ADD CONSTRAINT web_forumpostlike_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_forumpostlike + ADD CONSTRAINT web_forumpostlike_unique UNIQUE (post_id, user_id); + +ALTER TABLE ONLY public.web_forumpostversion + ADD CONSTRAINT web_forumpostversion_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_forumsection + ADD CONSTRAINT web_forumsection_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_forumthread + ADD CONSTRAINT web_forumthread_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_invitelink + ADD CONSTRAINT web_invitelink_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_role_permissions + ADD CONSTRAINT web_role_permissions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_role_permissions + ADD CONSTRAINT web_role_permissions_role_id_permission_id_312e20b5_uniq UNIQUE (role_id, permission_id); + +ALTER TABLE ONLY public.web_role + ADD CONSTRAINT web_role_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_role_restrictions + ADD CONSTRAINT web_role_restrictions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_role_restrictions + ADD CONSTRAINT web_role_restrictions_role_id_permission_id_278f6fcc_uniq UNIQUE (role_id, permission_id); + +ALTER TABLE ONLY public.web_role + ADD CONSTRAINT web_role_slug_key UNIQUE (slug); + +ALTER TABLE ONLY public.web_rolecategory + ADD CONSTRAINT web_rolecategory_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_rolepermissionsoverride_restrictions + ADD CONSTRAINT web_rolepermissionsoverr_rolepermissionsoverride__3ee74f77_uniq UNIQUE (rolepermissionsoverride_id, permission_id); + +ALTER TABLE ONLY public.web_rolepermissionsoverride_permissions + ADD CONSTRAINT web_rolepermissionsoverr_rolepermissionsoverride__e830b24d_uniq UNIQUE (rolepermissionsoverride_id, permission_id); + +ALTER TABLE ONLY public.web_rolepermissionsoverride_permissions + ADD CONSTRAINT web_rolepermissionsoverride_permissions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_rolepermissionsoverride + ADD CONSTRAINT web_rolepermissionsoverride_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_rolepermissionsoverride_restrictions + ADD CONSTRAINT web_rolepermissionsoverride_restrictions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_settings + ADD CONSTRAINT web_settings_category_id_key UNIQUE (category_id); + +ALTER TABLE ONLY public.web_settings + ADD CONSTRAINT web_settings_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_settings + ADD CONSTRAINT web_settings_site_id_key UNIQUE (site_id); + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_domain_unique UNIQUE (domain); + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_slug_unique UNIQUE (slug); + +ALTER TABLE ONLY public.web_tag + ADD CONSTRAINT web_tag_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_tag + ADD CONSTRAINT web_tag_unique UNIQUE (category_id, name); + +ALTER TABLE ONLY public.web_tagscategory + ADD CONSTRAINT web_tagscategory_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_tagscategory + ADD CONSTRAINT web_tagscategory_priority_fd2df012_uniq UNIQUE (priority); + +ALTER TABLE ONLY public.web_tagscategory + ADD CONSTRAINT web_tagscategory_slug_key UNIQUE (slug); + +ALTER TABLE ONLY public.web_tagscategory + ADD CONSTRAINT web_tagscategory_unique UNIQUE (slug); + +ALTER TABLE ONLY public.web_theme + ADD CONSTRAINT web_theme_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_theme + ADD CONSTRAINT web_theme_slug_7893de0e_uniq UNIQUE (slug); + +ALTER TABLE ONLY public.web_usedtoken + ADD CONSTRAINT web_usedtoken_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_user + ADD CONSTRAINT web_user_api_key_key UNIQUE (api_key); + +ALTER TABLE ONLY public.web_user_groups + ADD CONSTRAINT web_user_groups_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_user_groups + ADD CONSTRAINT web_user_groups_user_id_group_id_ad6e87b6_uniq UNIQUE (user_id, group_id); + +ALTER TABLE ONLY public.web_user + ADD CONSTRAINT web_user_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_user_roles + ADD CONSTRAINT web_user_roles_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_user_roles + ADD CONSTRAINT web_user_roles_user_id_role_id_779385a6_uniq UNIQUE (user_id, role_id); + +ALTER TABLE ONLY public.web_user_user_permissions + ADD CONSTRAINT web_user_user_permissions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_user_user_permissions + ADD CONSTRAINT web_user_user_permissions_user_id_permission_id_b62696d3_uniq UNIQUE (user_id, permission_id); + +ALTER TABLE ONLY public.web_user + ADD CONSTRAINT web_user_username_key UNIQUE (username); + +ALTER TABLE ONLY public.web_user + ADD CONSTRAINT web_user_wikidot_username_key UNIQUE (wikidot_username); + +ALTER TABLE ONLY public.web_usernotification + ADD CONSTRAINT web_usernotification_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_usernotificationmapping + ADD CONSTRAINT web_usernotificationmapping_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_usernotificationsubscription + ADD CONSTRAINT web_usernotificationsubscription_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_userreport + ADD CONSTRAINT web_userreport_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_userticket + ADD CONSTRAINT web_userticket_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_vote + ADD CONSTRAINT web_vote_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.web_vote + ADD CONSTRAINT web_vote_unique UNIQUE (article_id, user_id); + +CREATE INDEX article_search_plaintext_gin ON public.web_articlesearchindex USING gin (content_plaintext public.gin_trgm_ops); + +CREATE INDEX article_search_source_gin ON public.web_articlesearchindex USING gin (content_source public.gin_trgm_ops); + +CREATE INDEX auth_group_name_a6ea08ec_like ON public.auth_group USING btree (name varchar_pattern_ops); + +CREATE INDEX auth_group_permissions_group_id_b120cbf9 ON public.auth_group_permissions USING btree (group_id); + +CREATE INDEX auth_group_permissions_permission_id_84c5c92e ON public.auth_group_permissions USING btree (permission_id); + +CREATE INDEX auth_permission_content_type_id_2f476e4b ON public.auth_permission USING btree (content_type_id); + +CREATE INDEX content_source_lower_trgm_gin ON public.web_articlesearchindex USING gin (upper(content_source) public.gin_trgm_ops); + +CREATE INDEX django_admin_log_content_type_id_c4bce8eb ON public.django_admin_log USING btree (content_type_id); + +CREATE INDEX django_admin_log_user_id_c564eba6 ON public.django_admin_log USING btree (user_id); + +CREATE INDEX django_session_expire_date_a5c62663 ON public.django_session USING btree (expire_date); + +CREATE INDEX django_session_session_key_c0390e0f_like ON public.django_session USING btree (session_key varchar_pattern_ops); + +CREATE INDEX dynamic_preferences_globalpreferencemodel_name_033debe0 ON public.dynamic_preferences_globalpreferencemodel USING btree (name); + +CREATE INDEX dynamic_preferences_globalpreferencemodel_name_033debe0_like ON public.dynamic_preferences_globalpreferencemodel USING btree (name varchar_pattern_ops); + +CREATE INDEX dynamic_preferences_globalpreferencemodel_section_c1ee9cc3 ON public.dynamic_preferences_globalpreferencemodel USING btree (section); + +CREATE INDEX dynamic_preferences_globalpreferencemodel_section_c1ee9cc3_like ON public.dynamic_preferences_globalpreferencemodel USING btree (section varchar_pattern_ops); + +CREATE INDEX dynamic_preferences_user_name_11ac488d_like ON public.dynamic_preferences_users_userpreferencemodel USING btree (name varchar_pattern_ops); + +CREATE INDEX dynamic_preferences_user_section_ba869570_like ON public.dynamic_preferences_users_userpreferencemodel USING btree (section varchar_pattern_ops); + +CREATE INDEX dynamic_preferences_users__instance_id_bf1d7718 ON public.dynamic_preferences_users_userpreferencemodel USING btree (instance_id); + +CREATE INDEX dynamic_preferences_users_userpreferencemodel_name_11ac488d ON public.dynamic_preferences_users_userpreferencemodel USING btree (name); + +CREATE INDEX dynamic_preferences_users_userpreferencemodel_section_ba869570 ON public.dynamic_preferences_users_userpreferencemodel USING btree (section); + +CREATE UNIQUE INDEX user_email_ci_uniqueness ON public.web_user USING btree (lower((email)::text)) WHERE ((email IS NOT NULL) AND (NOT ((email)::text = ''::text)) AND (email_verified_at IS NOT NULL)); + +CREATE INDEX user_origin_ip_idx ON public.web_actionlogentry USING btree (user_id, origin_ip); + +CREATE INDEX web_actionlogentry_user_id_f7d6b7fd ON public.web_actionlogentry USING btree (user_id); + +CREATE INDEX web_article_article_5eb4fb_idx ON public.web_articlesearchindex USING btree (article_id); + +CREATE INDEX web_article_article_eb894c_idx ON public.web_articleversion USING btree (article_id, created_at); + +CREATE INDEX web_article_authors_article_id_d2bd8022 ON public.web_article_authors USING btree (article_id); + +CREATE INDEX web_article_authors_user_id_d6678673 ON public.web_article_authors USING btree (user_id); + +CREATE INDEX web_article_categor_7c81ec_idx ON public.web_article USING btree (category); + +CREATE INDEX web_article_complet_ea3782_idx ON public.web_article USING btree (complete_full_name); + +CREATE INDEX web_article_created_5524a8_idx ON public.web_article USING btree (created_at); + +CREATE INDEX web_article_media_name_6fe9ffb3_like ON public.web_article USING btree (media_name text_pattern_ops); + +CREATE INDEX web_article_name_1c14b5_idx ON public.web_article USING btree (name); + +CREATE INDEX web_article_parent_id_c28f9c8e ON public.web_article USING btree (parent_id); + +CREATE INDEX web_article_tags_article_id_41521625 ON public.web_article_tags USING btree (article_id); + +CREATE INDEX web_article_tags_tag_id_0c6e67dc ON public.web_article_tags USING btree (tag_id); + +CREATE INDEX web_article_updated_20aa03_idx ON public.web_article USING btree (updated_at); + +CREATE INDEX web_article_user_id_e697e3_idx ON public.web_articlefavourite USING btree (user_id, created_at); + +CREATE INDEX web_article_vector__0d2eb2_gin ON public.web_articlesearchindex USING gin (vector_plaintext); + +CREATE INDEX web_articlefavourite_article_id_d2101d7d ON public.web_articlefavourite USING btree (article_id); + +CREATE INDEX web_articlefavourite_user_id_04b77838 ON public.web_articlefavourite USING btree (user_id); + +CREATE INDEX web_articlelogentry_article_id_c91527a4 ON public.web_articlelogentry USING btree (article_id); + +CREATE INDEX web_articlelogentry_user_id_4901d7e7 ON public.web_articlelogentry USING btree (user_id); + +CREATE INDEX web_articlesearchindex_article_id_56e2d290 ON public.web_articlesearchindex USING btree (article_id); + +CREATE INDEX web_articleversion_article_id_bdc77ae9 ON public.web_articleversion USING btree (article_id); + +CREATE INDEX web_categor_name_e51bae_idx ON public.web_category USING btree (name); + +CREATE INDEX web_category_permissions_o_rolepermissionsoverride_id_00181793 ON public.web_category_permissions_override USING btree (rolepermissionsoverride_id); + +CREATE INDEX web_category_permissions_override_category_id_ef6a965d ON public.web_category_permissions_override USING btree (category_id); + +CREATE INDEX web_directm_recipie_07e335_idx ON public.web_directmessage USING btree (recipient_id, is_read); + +CREATE INDEX web_directm_recipie_a11daa_idx ON public.web_directmessage USING btree (recipient_id, sender_id, created_at); + +CREATE INDEX web_directm_sender__630662_idx ON public.web_directmessage USING btree (sender_id, recipient_id, created_at); + +CREATE INDEX web_directmessage_recipient_id_8f17ea11 ON public.web_directmessage USING btree (recipient_id); + +CREATE INDEX web_directmessage_sender_id_6d18f1b0 ON public.web_directmessage USING btree (sender_id); + +CREATE INDEX web_directmessageblock_blocked_id_352fa5c1 ON public.web_directmessageblock USING btree (blocked_id); + +CREATE INDEX web_directmessageblock_blocker_id_4af74198 ON public.web_directmessageblock USING btree (blocker_id); + +CREATE INDEX web_externa_link_fr_92155a_idx ON public.web_externallink USING btree (link_from, link_to); + +CREATE INDEX web_externa_link_ty_8f2d31_idx ON public.web_externallink USING btree (link_type); + +CREATE INDEX web_file_article_c1ccbd_idx ON public.web_file USING btree (article_id, name); + +CREATE INDEX web_file_article_id_934be460 ON public.web_file USING btree (article_id); + +CREATE INDEX web_file_author_id_071a487b ON public.web_file USING btree (author_id); + +CREATE INDEX web_file_deleted_by_id_738af55a ON public.web_file USING btree (deleted_by_id); + +CREATE INDEX web_forumcategory_section_id_9eb597c6 ON public.web_forumcategory USING btree (section_id); + +CREATE INDEX web_forumpo_post_id_3cdbb2_idx ON public.web_forumpostlike USING btree (post_id, created_at); + +CREATE INDEX web_forumpost_author_id_f9f7ffa6 ON public.web_forumpost USING btree (author_id); + +CREATE INDEX web_forumpost_reply_to_id_74e94c63 ON public.web_forumpost USING btree (reply_to_id); + +CREATE INDEX web_forumpost_thread_id_80c1c8ee ON public.web_forumpost USING btree (thread_id); + +CREATE INDEX web_forumpostlike_post_id_4254c592 ON public.web_forumpostlike USING btree (post_id); + +CREATE INDEX web_forumpostlike_user_id_ef0d1f88 ON public.web_forumpostlike USING btree (user_id); + +CREATE INDEX web_forumpostversion_author_id_1e418670 ON public.web_forumpostversion USING btree (author_id); + +CREATE INDEX web_forumpostversion_post_id_90ba32cc ON public.web_forumpostversion USING btree (post_id); + +CREATE INDEX web_forumthread_article_id_6e450731 ON public.web_forumthread USING btree (article_id); + +CREATE INDEX web_forumthread_author_id_f4b5c652 ON public.web_forumthread USING btree (author_id); + +CREATE INDEX web_forumthread_category_id_ba3b2f7a ON public.web_forumthread USING btree (category_id); + +CREATE INDEX web_invitel_kind_bfa536_idx ON public.web_invitelink USING btree (kind, activated_at); + +CREATE INDEX web_invitel_token_4bbe08_idx ON public.web_invitelink USING btree (token); + +CREATE INDEX web_invitelink_created_by_id_4a5c7522 ON public.web_invitelink USING btree (created_by_id); + +CREATE INDEX web_invitelink_target_id_9066929c ON public.web_invitelink USING btree (target_id); + +CREATE INDEX web_role_categor_bd0076_idx ON public.web_role USING btree (category_id); + +CREATE INDEX web_role_category_id_92fe1732 ON public.web_role USING btree (category_id); + +CREATE INDEX web_role_index_06692c9c ON public.web_role USING btree (index); + +CREATE INDEX web_role_permissions_permission_id_b763a60e ON public.web_role_permissions USING btree (permission_id); + +CREATE INDEX web_role_permissions_role_id_c7563a4c ON public.web_role_permissions USING btree (role_id); + +CREATE INDEX web_role_restrictions_permission_id_2c443339 ON public.web_role_restrictions USING btree (permission_id); + +CREATE INDEX web_role_restrictions_role_id_6a244194 ON public.web_role_restrictions USING btree (role_id); + +CREATE INDEX web_role_slug_06c2fa_idx ON public.web_role USING btree (slug); + +CREATE INDEX web_role_slug_2fac51ce_like ON public.web_role USING btree (slug varchar_pattern_ops); + +CREATE INDEX web_rolepermissionsoverrid_rolepermissionsoverride_id_566b0804 ON public.web_rolepermissionsoverride_restrictions USING btree (rolepermissionsoverride_id); + +CREATE INDEX web_rolepermissionsoverrid_rolepermissionsoverride_id_b44bd213 ON public.web_rolepermissionsoverride_permissions USING btree (rolepermissionsoverride_id); + +CREATE INDEX web_rolepermissionsoverride_permissions_permission_id_3852bbc1 ON public.web_rolepermissionsoverride_permissions USING btree (permission_id); + +CREATE INDEX web_rolepermissionsoverride_restrictions_permission_id_f4a52b6c ON public.web_rolepermissionsoverride_restrictions USING btree (permission_id); + +CREATE INDEX web_rolepermissionsoverride_role_id_2a968f02 ON public.web_rolepermissionsoverride USING btree (role_id); + +CREATE INDEX web_site_active_theme_id_da6d0001 ON public.web_site USING btree (active_theme_id); + +CREATE INDEX web_site_default_role_id_7592183b ON public.web_site USING btree (default_role_id); + +CREATE INDEX web_site_membership_password_role_id_b9704f29 ON public.web_site USING btree (membership_password_role_id); + +CREATE INDEX web_site_system_theme_id_idx ON public.web_site USING btree (system_theme_id); + +CREATE INDEX web_site_verified_role_id_7a0a8c1f ON public.web_site USING btree (verified_role_id); + +CREATE INDEX web_tag_categor_b8c5d5_idx ON public.web_tag USING btree (category_id, name); + +CREATE INDEX web_tag_category_id_3b00d4b5 ON public.web_tag USING btree (category_id); + +CREATE INDEX web_tagscat_name_19e56e_idx ON public.web_tagscategory USING btree (name); + +CREATE INDEX web_tagscategory_slug_7dd396d8_like ON public.web_tagscategory USING btree (slug text_pattern_ops); + +CREATE INDEX web_theme_slug_7893de0e_like ON public.web_theme USING btree (slug text_pattern_ops); + +CREATE INDEX web_user_api_key_3b1c35ab_like ON public.web_user USING btree (api_key varchar_pattern_ops); + +CREATE INDEX web_user_groups_group_id_b03a95c5 ON public.web_user_groups USING btree (group_id); + +CREATE INDEX web_user_groups_user_id_ddeacfa4 ON public.web_user_groups USING btree (user_id); + +CREATE INDEX web_user_roles_role_id_e6a205fe ON public.web_user_roles USING btree (role_id); + +CREATE INDEX web_user_roles_user_id_6316cd15 ON public.web_user_roles USING btree (user_id); + +CREATE INDEX web_user_user_permissions_permission_id_360139a1 ON public.web_user_user_permissions USING btree (permission_id); + +CREATE INDEX web_user_user_permissions_user_id_19dddb18 ON public.web_user_user_permissions USING btree (user_id); + +CREATE INDEX web_usernotificationmapping_notification_id_62522092 ON public.web_usernotificationmapping USING btree (notification_id); + +CREATE INDEX web_usernotificationmapping_recipient_id_f32bbf43 ON public.web_usernotificationmapping USING btree (recipient_id); + +CREATE INDEX web_usernotificationsubscription_article_id_1a8fe391 ON public.web_usernotificationsubscription USING btree (article_id); + +CREATE INDEX web_usernotificationsubscription_forum_thread_id_0d41b7a1 ON public.web_usernotificationsubscription USING btree (forum_thread_id); + +CREATE INDEX web_usernotificationsubscription_subscriber_id_5f372c01 ON public.web_usernotificationsubscription USING btree (subscriber_id); + +CREATE INDEX web_userrep_reporte_77ecac_idx ON public.web_userreport USING btree (reported_id, status); + +CREATE INDEX web_userrep_status_466a49_idx ON public.web_userreport USING btree (status, created_at); + +CREATE INDEX web_userreport_reported_id_e5d9fce9 ON public.web_userreport USING btree (reported_id); + +CREATE INDEX web_userreport_reporter_id_ac8a30a8 ON public.web_userreport USING btree (reporter_id); + +CREATE INDEX web_userreport_reviewed_by_id_d8490d57 ON public.web_userreport USING btree (reviewed_by_id); + +CREATE INDEX web_usertic_author__6ccbb3_idx ON public.web_userticket USING btree (author_id, kind); + +CREATE INDEX web_usertic_kind_b5423e_idx ON public.web_userticket USING btree (kind, status, created_at); + +CREATE INDEX web_userticket_author_id_5e9657e0 ON public.web_userticket USING btree (author_id); + +CREATE INDEX web_userticket_granted_role_id_0089f47e ON public.web_userticket USING btree (granted_role_id); + +CREATE INDEX web_userticket_reviewed_by_id_2f06c30c ON public.web_userticket USING btree (reviewed_by_id); + +CREATE INDEX web_vote_article_a54b49_idx ON public.web_vote USING btree (article_id); + +CREATE INDEX web_vote_article_id_4a8b8b96 ON public.web_vote USING btree (article_id); + +CREATE INDEX web_vote_role_id_01ee7909 ON public.web_vote USING btree (role_id); + +CREATE INDEX web_vote_user_id_591bef6d ON public.web_vote USING btree (user_id); + +ALTER TABLE ONLY public.auth_group_permissions + ADD CONSTRAINT auth_group_permissio_permission_id_84c5c92e_fk_auth_perm FOREIGN KEY (permission_id) REFERENCES public.auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.auth_group_permissions + ADD CONSTRAINT auth_group_permissions_group_id_b120cbf9_fk_auth_group_id FOREIGN KEY (group_id) REFERENCES public.auth_group(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.auth_permission + ADD CONSTRAINT auth_permission_content_type_id_2f476e4b_fk_django_co FOREIGN KEY (content_type_id) REFERENCES public.django_content_type(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.django_admin_log + ADD CONSTRAINT django_admin_log_content_type_id_c4bce8eb_fk_django_co FOREIGN KEY (content_type_id) REFERENCES public.django_content_type(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.django_admin_log + ADD CONSTRAINT django_admin_log_user_id_c564eba6_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.dynamic_preferences_users_userpreferencemodel + ADD CONSTRAINT dynamic_preferences__instance_id_bf1d7718_fk_web_user_ FOREIGN KEY (instance_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_actionlogentry + ADD CONSTRAINT web_actionlogentry_user_id_f7d6b7fd_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_article_authors + ADD CONSTRAINT web_article_authors_article_id_d2bd8022_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_article_authors + ADD CONSTRAINT web_article_authors_user_id_d6678673_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_article + ADD CONSTRAINT web_article_parent_id_c28f9c8e_fk_web_article_id FOREIGN KEY (parent_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_article_tags + ADD CONSTRAINT web_article_tags_article_id_41521625_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_article_tags + ADD CONSTRAINT web_article_tags_tag_id_0c6e67dc_fk_web_tag_id FOREIGN KEY (tag_id) REFERENCES public.web_tag(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_articlefavourite + ADD CONSTRAINT web_articlefavourite_article_id_d2101d7d_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_articlefavourite + ADD CONSTRAINT web_articlefavourite_user_id_04b77838_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_articlelogentry + ADD CONSTRAINT web_articlelogentry_article_id_c91527a4_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_articlelogentry + ADD CONSTRAINT web_articlelogentry_user_id_4901d7e7_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_articlesearchindex + ADD CONSTRAINT web_articlesearchindex_article_id_56e2d290_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_articleversion + ADD CONSTRAINT web_articleversion_article_id_bdc77ae9_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_category_permissions_override + ADD CONSTRAINT web_category_permiss_category_id_ef6a965d_fk_web_categ FOREIGN KEY (category_id) REFERENCES public.web_category(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_category_permissions_override + ADD CONSTRAINT web_category_permiss_rolepermissionsoverr_00181793_fk_web_rolep FOREIGN KEY (rolepermissionsoverride_id) REFERENCES public.web_rolepermissionsoverride(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_directmessage + ADD CONSTRAINT web_directmessage_recipient_id_8f17ea11_fk_web_user_id FOREIGN KEY (recipient_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_directmessage + ADD CONSTRAINT web_directmessage_sender_id_6d18f1b0_fk_web_user_id FOREIGN KEY (sender_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_directmessageblock + ADD CONSTRAINT web_directmessageblock_blocked_id_352fa5c1_fk_web_user_id FOREIGN KEY (blocked_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_directmessageblock + ADD CONSTRAINT web_directmessageblock_blocker_id_4af74198_fk_web_user_id FOREIGN KEY (blocker_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_file + ADD CONSTRAINT web_file_article_id_934be460_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_file + ADD CONSTRAINT web_file_author_id_071a487b_fk_web_user_id FOREIGN KEY (author_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_file + ADD CONSTRAINT web_file_deleted_by_id_738af55a_fk_web_user_id FOREIGN KEY (deleted_by_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumcategory + ADD CONSTRAINT web_forumcategory_section_id_9eb597c6_fk_web_forumsection_id FOREIGN KEY (section_id) REFERENCES public.web_forumsection(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpost + ADD CONSTRAINT web_forumpost_author_id_f9f7ffa6_fk_web_user_id FOREIGN KEY (author_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpost + ADD CONSTRAINT web_forumpost_reply_to_id_74e94c63_fk_web_forumpost_id FOREIGN KEY (reply_to_id) REFERENCES public.web_forumpost(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpost + ADD CONSTRAINT web_forumpost_thread_id_80c1c8ee_fk_web_forumthread_id FOREIGN KEY (thread_id) REFERENCES public.web_forumthread(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpostlike + ADD CONSTRAINT web_forumpostlike_post_id_4254c592_fk_web_forumpost_id FOREIGN KEY (post_id) REFERENCES public.web_forumpost(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpostlike + ADD CONSTRAINT web_forumpostlike_user_id_ef0d1f88_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpostversion + ADD CONSTRAINT web_forumpostversion_author_id_1e418670_fk_web_user_id FOREIGN KEY (author_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumpostversion + ADD CONSTRAINT web_forumpostversion_post_id_90ba32cc_fk_web_forumpost_id FOREIGN KEY (post_id) REFERENCES public.web_forumpost(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumthread + ADD CONSTRAINT web_forumthread_article_id_6e450731_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumthread + ADD CONSTRAINT web_forumthread_author_id_f4b5c652_fk_web_user_id FOREIGN KEY (author_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_forumthread + ADD CONSTRAINT web_forumthread_category_id_ba3b2f7a_fk_web_forumcategory_id FOREIGN KEY (category_id) REFERENCES public.web_forumcategory(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_invitelink + ADD CONSTRAINT web_invitelink_created_by_id_4a5c7522_fk_web_user_id FOREIGN KEY (created_by_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_invitelink + ADD CONSTRAINT web_invitelink_target_id_9066929c_fk_web_user_id FOREIGN KEY (target_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_role + ADD CONSTRAINT web_role_category_id_92fe1732_fk_web_rolecategory_id FOREIGN KEY (category_id) REFERENCES public.web_rolecategory(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_role_permissions + ADD CONSTRAINT web_role_permissions_permission_id_b763a60e_fk_auth_perm FOREIGN KEY (permission_id) REFERENCES public.auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_role_permissions + ADD CONSTRAINT web_role_permissions_role_id_c7563a4c_fk_web_role_id FOREIGN KEY (role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_role_restrictions + ADD CONSTRAINT web_role_restriction_permission_id_2c443339_fk_auth_perm FOREIGN KEY (permission_id) REFERENCES public.auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_role_restrictions + ADD CONSTRAINT web_role_restrictions_role_id_6a244194_fk_web_role_id FOREIGN KEY (role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_rolepermissionsoverride_permissions + ADD CONSTRAINT web_rolepermissionso_permission_id_3852bbc1_fk_auth_perm FOREIGN KEY (permission_id) REFERENCES public.auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_rolepermissionsoverride_restrictions + ADD CONSTRAINT web_rolepermissionso_permission_id_f4a52b6c_fk_auth_perm FOREIGN KEY (permission_id) REFERENCES public.auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_rolepermissionsoverride_restrictions + ADD CONSTRAINT web_rolepermissionso_rolepermissionsoverr_566b0804_fk_web_rolep FOREIGN KEY (rolepermissionsoverride_id) REFERENCES public.web_rolepermissionsoverride(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_rolepermissionsoverride_permissions + ADD CONSTRAINT web_rolepermissionso_rolepermissionsoverr_b44bd213_fk_web_rolep FOREIGN KEY (rolepermissionsoverride_id) REFERENCES public.web_rolepermissionsoverride(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_rolepermissionsoverride + ADD CONSTRAINT web_rolepermissionsoverride_role_id_2a968f02_fk_web_role_id FOREIGN KEY (role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_settings + ADD CONSTRAINT web_settings_category_id_6ae50e29_fk_web_category_id FOREIGN KEY (category_id) REFERENCES public.web_category(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_settings + ADD CONSTRAINT web_settings_site_id_d08a2747_fk_web_site_id FOREIGN KEY (site_id) REFERENCES public.web_site(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_active_theme_id_da6d0001_fk_web_theme_id FOREIGN KEY (active_theme_id) REFERENCES public.web_theme(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_default_role_id_7592183b_fk_web_role_id FOREIGN KEY (default_role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_membership_password_role_id_b9704f29_fk_web_role_id FOREIGN KEY (membership_password_role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_system_theme_id_fkey FOREIGN KEY (system_theme_id) REFERENCES public.web_theme(id) ON DELETE SET NULL; + +ALTER TABLE ONLY public.web_site + ADD CONSTRAINT web_site_verified_role_id_7a0a8c1f_fk_web_role_id FOREIGN KEY (verified_role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_tag + ADD CONSTRAINT web_tag_category_id_3b00d4b5_fk_web_tagscategory_id FOREIGN KEY (category_id) REFERENCES public.web_tagscategory(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_user_groups + ADD CONSTRAINT web_user_groups_group_id_b03a95c5_fk_auth_group_id FOREIGN KEY (group_id) REFERENCES public.auth_group(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_user_groups + ADD CONSTRAINT web_user_groups_user_id_ddeacfa4_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_user_roles + ADD CONSTRAINT web_user_roles_role_id_e6a205fe_fk_web_role_id FOREIGN KEY (role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_user_roles + ADD CONSTRAINT web_user_roles_user_id_6316cd15_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_user_user_permissions + ADD CONSTRAINT web_user_user_permis_permission_id_360139a1_fk_auth_perm FOREIGN KEY (permission_id) REFERENCES public.auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_user_user_permissions + ADD CONSTRAINT web_user_user_permissions_user_id_19dddb18_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_usernotificationsubscription + ADD CONSTRAINT web_usernotification_article_id_1a8fe391_fk_web_artic FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_usernotificationsubscription + ADD CONSTRAINT web_usernotification_forum_thread_id_0d41b7a1_fk_web_forum FOREIGN KEY (forum_thread_id) REFERENCES public.web_forumthread(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_usernotificationmapping + ADD CONSTRAINT web_usernotification_notification_id_62522092_fk_web_usern FOREIGN KEY (notification_id) REFERENCES public.web_usernotification(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_usernotificationmapping + ADD CONSTRAINT web_usernotification_recipient_id_f32bbf43_fk_web_user_ FOREIGN KEY (recipient_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_usernotificationsubscription + ADD CONSTRAINT web_usernotification_subscriber_id_5f372c01_fk_web_user_ FOREIGN KEY (subscriber_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_userreport + ADD CONSTRAINT web_userreport_reported_id_e5d9fce9_fk_web_user_id FOREIGN KEY (reported_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_userreport + ADD CONSTRAINT web_userreport_reporter_id_ac8a30a8_fk_web_user_id FOREIGN KEY (reporter_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_userreport + ADD CONSTRAINT web_userreport_reviewed_by_id_d8490d57_fk_web_user_id FOREIGN KEY (reviewed_by_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_userticket + ADD CONSTRAINT web_userticket_author_id_5e9657e0_fk_web_user_id FOREIGN KEY (author_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_userticket + ADD CONSTRAINT web_userticket_granted_role_id_0089f47e_fk_web_role_id FOREIGN KEY (granted_role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_userticket + ADD CONSTRAINT web_userticket_reviewed_by_id_2f06c30c_fk_web_user_id FOREIGN KEY (reviewed_by_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_vote + ADD CONSTRAINT web_vote_article_id_4a8b8b96_fk_web_article_id FOREIGN KEY (article_id) REFERENCES public.web_article(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_vote + ADD CONSTRAINT web_vote_role_id_01ee7909_fk_web_role_id FOREIGN KEY (role_id) REFERENCES public.web_role(id) DEFERRABLE INITIALLY DEFERRED; + +ALTER TABLE ONLY public.web_vote + ADD CONSTRAINT web_vote_user_id_591bef6d_fk_web_user_id FOREIGN KEY (user_id) REFERENCES public.web_user(id) DEFERRABLE INITIALLY DEFERRED; + +INSERT INTO public.django_content_type VALUES (1, 'web', 'roles'); +INSERT INTO public.django_content_type VALUES (2, 'admin', 'logentry'); +INSERT INTO public.django_content_type VALUES (3, 'auth', 'permission'); +INSERT INTO public.django_content_type VALUES (4, 'auth', 'group'); +INSERT INTO public.django_content_type VALUES (5, 'contenttypes', 'contenttype'); +INSERT INTO public.django_content_type VALUES (6, 'sessions', 'session'); +INSERT INTO public.django_content_type VALUES (7, 'dynamic_preferences', 'globalpreferencemodel'); +INSERT INTO public.django_content_type VALUES (8, 'dynamic_preferences_users', 'userpreferencemodel'); +INSERT INTO public.django_content_type VALUES (9, 'web', 'usedtoken'); +INSERT INTO public.django_content_type VALUES (10, 'web', 'user'); +INSERT INTO public.django_content_type VALUES (11, 'web', 'article'); +INSERT INTO public.django_content_type VALUES (12, 'web', 'settings'); +INSERT INTO public.django_content_type VALUES (13, 'web', 'site'); +INSERT INTO public.django_content_type VALUES (14, 'web', 'vote'); +INSERT INTO public.django_content_type VALUES (15, 'web', 'tag'); +INSERT INTO public.django_content_type VALUES (16, 'web', 'file'); +INSERT INTO public.django_content_type VALUES (17, 'web', 'category'); +INSERT INTO public.django_content_type VALUES (18, 'web', 'articleversion'); +INSERT INTO public.django_content_type VALUES (19, 'web', 'articlelogentry'); +INSERT INTO public.django_content_type VALUES (20, 'web', 'externallink'); +INSERT INTO public.django_content_type VALUES (21, 'web', 'forumcategory'); +INSERT INTO public.django_content_type VALUES (22, 'web', 'forumpost'); +INSERT INTO public.django_content_type VALUES (23, 'web', 'forumthread'); +INSERT INTO public.django_content_type VALUES (24, 'web', 'forumsection'); +INSERT INTO public.django_content_type VALUES (25, 'web', 'forumpostversion'); +INSERT INTO public.django_content_type VALUES (26, 'web', 'tagscategory'); +INSERT INTO public.django_content_type VALUES (27, 'web', 'actionlogentry'); +INSERT INTO public.django_content_type VALUES (28, 'web', 'usernotification'); +INSERT INTO public.django_content_type VALUES (29, 'web', 'usernotificationmapping'); +INSERT INTO public.django_content_type VALUES (30, 'web', 'usernotificationsubscription'); +INSERT INTO public.django_content_type VALUES (31, 'web', 'articlesearchindex'); +INSERT INTO public.django_content_type VALUES (32, 'web', 'rolecategory'); +INSERT INTO public.django_content_type VALUES (33, 'web', 'role'); +INSERT INTO public.django_content_type VALUES (34, 'web', 'rolepermissionsoverride'); +INSERT INTO public.django_content_type VALUES (35, 'web', 'directmessage'); +INSERT INTO public.django_content_type VALUES (36, 'web', 'directmessageblock'); +INSERT INTO public.django_content_type VALUES (37, 'web', 'userreport'); +INSERT INTO public.django_content_type VALUES (38, 'web', 'theme'); +INSERT INTO public.django_content_type VALUES (39, 'web', 'systemupdate'); +INSERT INTO public.django_content_type VALUES (40, 'web', 'userticket'); +INSERT INTO public.django_content_type VALUES (41, 'web', 'membershipapplication'); +INSERT INTO public.django_content_type VALUES (42, 'web', 'supportticket'); +INSERT INTO public.django_content_type VALUES (43, 'web', 'invitelink'); +INSERT INTO public.django_content_type VALUES (44, 'web', 'forumpostlike'); +INSERT INTO public.django_content_type VALUES (45, 'web', 'articlefavourite'); + +INSERT INTO public.auth_permission VALUES (1, '', 1, 'view_articles'); +INSERT INTO public.auth_permission VALUES (2, '', 1, 'rate_articles'); +INSERT INTO public.auth_permission VALUES (3, '', 1, 'create_articles'); +INSERT INTO public.auth_permission VALUES (4, '', 1, 'edit_articles'); +INSERT INTO public.auth_permission VALUES (5, '', 1, 'tag_articles'); +INSERT INTO public.auth_permission VALUES (6, '', 1, 'move_articles'); +INSERT INTO public.auth_permission VALUES (7, '', 1, 'lock_articles'); +INSERT INTO public.auth_permission VALUES (8, '', 1, 'manage_article_files'); +INSERT INTO public.auth_permission VALUES (9, '', 1, 'delete_articles'); +INSERT INTO public.auth_permission VALUES (10, '', 1, 'reset_article_votes'); +INSERT INTO public.auth_permission VALUES (11, '', 1, 'comment_articles'); +INSERT INTO public.auth_permission VALUES (12, '', 1, 'view_article_comments'); +INSERT INTO public.auth_permission VALUES (13, '', 1, 'manage_article_authors'); +INSERT INTO public.auth_permission VALUES (14, '', 1, 'view_forum_posts'); +INSERT INTO public.auth_permission VALUES (15, '', 1, 'create_forum_posts'); +INSERT INTO public.auth_permission VALUES (16, '', 1, 'edit_forum_posts'); +INSERT INTO public.auth_permission VALUES (17, '', 1, 'delete_forum_posts'); +INSERT INTO public.auth_permission VALUES (18, '', 1, 'view_forum_threads'); +INSERT INTO public.auth_permission VALUES (19, '', 1, 'create_forum_threads'); +INSERT INTO public.auth_permission VALUES (20, '', 1, 'edit_forum_threads'); +INSERT INTO public.auth_permission VALUES (21, '', 1, 'pin_forum_threads'); +INSERT INTO public.auth_permission VALUES (22, '', 1, 'lock_forum_threads'); +INSERT INTO public.auth_permission VALUES (23, '', 1, 'move_forum_threads'); +INSERT INTO public.auth_permission VALUES (24, '', 1, 'view_forum_sections'); +INSERT INTO public.auth_permission VALUES (25, '', 1, 'view_hidden_forum_sections'); +INSERT INTO public.auth_permission VALUES (26, '', 1, 'view_forum_categories'); +INSERT INTO public.auth_permission VALUES (27, '', 1, 'manage_users'); +INSERT INTO public.auth_permission VALUES (28, '', 1, 'manage_roles'); +INSERT INTO public.auth_permission VALUES (29, '', 1, 'manage_site'); +INSERT INTO public.auth_permission VALUES (30, '', 1, 'view_actions_log'); +INSERT INTO public.auth_permission VALUES (31, '', 1, 'manage_categories'); +INSERT INTO public.auth_permission VALUES (32, '', 1, 'manage_tags'); +INSERT INTO public.auth_permission VALUES (33, '', 1, 'manage_forum'); +INSERT INTO public.auth_permission VALUES (34, '', 1, 'view_sensitive_info'); +INSERT INTO public.auth_permission VALUES (35, '', 1, 'view_votes_timestamp'); +INSERT INTO public.auth_permission VALUES (36, '', 1, 'manage_updates'); +INSERT INTO public.auth_permission VALUES (37, '', 1, 'manage_permissions'); +INSERT INTO public.auth_permission VALUES (38, '', 1, 'send_direct_message'); +INSERT INTO public.auth_permission VALUES (39, '', 1, 'view_user_reports'); +INSERT INTO public.auth_permission VALUES (40, '', 1, 'view_reported_full_conversation'); +INSERT INTO public.auth_permission VALUES (41, '', 1, 'view_user_tickets'); +INSERT INTO public.auth_permission VALUES (42, '', 1, 'review_membership_applications'); +INSERT INTO public.auth_permission VALUES (43, 'Can add log entry', 2, 'add_logentry'); +INSERT INTO public.auth_permission VALUES (44, 'Can change log entry', 2, 'change_logentry'); +INSERT INTO public.auth_permission VALUES (45, 'Can delete log entry', 2, 'delete_logentry'); +INSERT INTO public.auth_permission VALUES (46, 'Can view log entry', 2, 'view_logentry'); +INSERT INTO public.auth_permission VALUES (47, 'Can add permission', 3, 'add_permission'); +INSERT INTO public.auth_permission VALUES (48, 'Can change permission', 3, 'change_permission'); +INSERT INTO public.auth_permission VALUES (49, 'Can delete permission', 3, 'delete_permission'); +INSERT INTO public.auth_permission VALUES (50, 'Can view permission', 3, 'view_permission'); +INSERT INTO public.auth_permission VALUES (51, 'Can add group', 4, 'add_group'); +INSERT INTO public.auth_permission VALUES (52, 'Can change group', 4, 'change_group'); +INSERT INTO public.auth_permission VALUES (53, 'Can delete group', 4, 'delete_group'); +INSERT INTO public.auth_permission VALUES (54, 'Can view group', 4, 'view_group'); +INSERT INTO public.auth_permission VALUES (55, 'Can add content type', 5, 'add_contenttype'); +INSERT INTO public.auth_permission VALUES (56, 'Can change content type', 5, 'change_contenttype'); +INSERT INTO public.auth_permission VALUES (57, 'Can delete content type', 5, 'delete_contenttype'); +INSERT INTO public.auth_permission VALUES (58, 'Can view content type', 5, 'view_contenttype'); +INSERT INTO public.auth_permission VALUES (59, 'Can add session', 6, 'add_session'); +INSERT INTO public.auth_permission VALUES (60, 'Can change session', 6, 'change_session'); +INSERT INTO public.auth_permission VALUES (61, 'Can delete session', 6, 'delete_session'); +INSERT INTO public.auth_permission VALUES (62, 'Can view session', 6, 'view_session'); +INSERT INTO public.auth_permission VALUES (63, 'Can add Global preference', 7, 'add_globalpreferencemodel'); +INSERT INTO public.auth_permission VALUES (64, 'Can change Global preference', 7, 'change_globalpreferencemodel'); +INSERT INTO public.auth_permission VALUES (65, 'Can delete Global preference', 7, 'delete_globalpreferencemodel'); +INSERT INTO public.auth_permission VALUES (66, 'Can view Global preference', 7, 'view_globalpreferencemodel'); +INSERT INTO public.auth_permission VALUES (67, 'Can add user preference', 8, 'add_userpreferencemodel'); +INSERT INTO public.auth_permission VALUES (68, 'Can change user preference', 8, 'change_userpreferencemodel'); +INSERT INTO public.auth_permission VALUES (69, 'Can delete user preference', 8, 'delete_userpreferencemodel'); +INSERT INTO public.auth_permission VALUES (70, 'Can view user preference', 8, 'view_userpreferencemodel'); +INSERT INTO public.auth_permission VALUES (71, 'Can add 已使用的令牌', 9, 'add_usedtoken'); +INSERT INTO public.auth_permission VALUES (72, 'Can change 已使用的令牌', 9, 'change_usedtoken'); +INSERT INTO public.auth_permission VALUES (73, 'Can delete 已使用的令牌', 9, 'delete_usedtoken'); +INSERT INTO public.auth_permission VALUES (74, 'Can view 已使用的令牌', 9, 'view_usedtoken'); +INSERT INTO public.auth_permission VALUES (75, 'Can add 用户', 10, 'add_user'); +INSERT INTO public.auth_permission VALUES (76, 'Can change 用户', 10, 'change_user'); +INSERT INTO public.auth_permission VALUES (77, 'Can delete 用户', 10, 'delete_user'); +INSERT INTO public.auth_permission VALUES (78, 'Can view 用户', 10, 'view_user'); +INSERT INTO public.auth_permission VALUES (79, 'Can add 文章', 11, 'add_article'); +INSERT INTO public.auth_permission VALUES (80, 'Can change 文章', 11, 'change_article'); +INSERT INTO public.auth_permission VALUES (81, 'Can delete 文章', 11, 'delete_article'); +INSERT INTO public.auth_permission VALUES (82, 'Can view 文章', 11, 'view_article'); +INSERT INTO public.auth_permission VALUES (83, 'Can add 设置', 12, 'add_settings'); +INSERT INTO public.auth_permission VALUES (84, 'Can change 设置', 12, 'change_settings'); +INSERT INTO public.auth_permission VALUES (85, 'Can delete 设置', 12, 'delete_settings'); +INSERT INTO public.auth_permission VALUES (86, 'Can view 设置', 12, 'view_settings'); +INSERT INTO public.auth_permission VALUES (87, 'Can add 站点', 13, 'add_site'); +INSERT INTO public.auth_permission VALUES (88, 'Can change 站点', 13, 'change_site'); +INSERT INTO public.auth_permission VALUES (89, 'Can delete 站点', 13, 'delete_site'); +INSERT INTO public.auth_permission VALUES (90, 'Can view 站点', 13, 'view_site'); +INSERT INTO public.auth_permission VALUES (91, 'Can add 评分', 14, 'add_vote'); +INSERT INTO public.auth_permission VALUES (92, 'Can change 评分', 14, 'change_vote'); +INSERT INTO public.auth_permission VALUES (93, 'Can delete 评分', 14, 'delete_vote'); +INSERT INTO public.auth_permission VALUES (94, 'Can view 评分', 14, 'view_vote'); +INSERT INTO public.auth_permission VALUES (95, 'Can add 标签', 15, 'add_tag'); +INSERT INTO public.auth_permission VALUES (96, 'Can change 标签', 15, 'change_tag'); +INSERT INTO public.auth_permission VALUES (97, 'Can delete 标签', 15, 'delete_tag'); +INSERT INTO public.auth_permission VALUES (98, 'Can view 标签', 15, 'view_tag'); +INSERT INTO public.auth_permission VALUES (99, 'Can add 文件', 16, 'add_file'); +INSERT INTO public.auth_permission VALUES (100, 'Can change 文件', 16, 'change_file'); +INSERT INTO public.auth_permission VALUES (101, 'Can delete 文件', 16, 'delete_file'); +INSERT INTO public.auth_permission VALUES (102, 'Can view 文件', 16, 'view_file'); +INSERT INTO public.auth_permission VALUES (103, 'Can add 分类设置', 17, 'add_category'); +INSERT INTO public.auth_permission VALUES (104, 'Can change 分类设置', 17, 'change_category'); +INSERT INTO public.auth_permission VALUES (105, 'Can delete 分类设置', 17, 'delete_category'); +INSERT INTO public.auth_permission VALUES (106, 'Can view 分类设置', 17, 'view_category'); +INSERT INTO public.auth_permission VALUES (107, 'Can add 文章版本', 18, 'add_articleversion'); +INSERT INTO public.auth_permission VALUES (108, 'Can change 文章版本', 18, 'change_articleversion'); +INSERT INTO public.auth_permission VALUES (109, 'Can delete 文章版本', 18, 'delete_articleversion'); +INSERT INTO public.auth_permission VALUES (110, 'Can view 文章版本', 18, 'view_articleversion'); +INSERT INTO public.auth_permission VALUES (111, 'Can add 日志条目', 19, 'add_articlelogentry'); +INSERT INTO public.auth_permission VALUES (112, 'Can change 日志条目', 19, 'change_articlelogentry'); +INSERT INTO public.auth_permission VALUES (113, 'Can delete 日志条目', 19, 'delete_articlelogentry'); +INSERT INTO public.auth_permission VALUES (114, 'Can view 日志条目', 19, 'view_articlelogentry'); +INSERT INTO public.auth_permission VALUES (115, 'Can add 链接关系', 20, 'add_externallink'); +INSERT INTO public.auth_permission VALUES (116, 'Can change 链接关系', 20, 'change_externallink'); +INSERT INTO public.auth_permission VALUES (117, 'Can delete 链接关系', 20, 'delete_externallink'); +INSERT INTO public.auth_permission VALUES (118, 'Can view 链接关系', 20, 'view_externallink'); +INSERT INTO public.auth_permission VALUES (119, 'Can add 论坛版块', 21, 'add_forumcategory'); +INSERT INTO public.auth_permission VALUES (120, 'Can change 论坛版块', 21, 'change_forumcategory'); +INSERT INTO public.auth_permission VALUES (121, 'Can delete 论坛版块', 21, 'delete_forumcategory'); +INSERT INTO public.auth_permission VALUES (122, 'Can view 论坛版块', 21, 'view_forumcategory'); +INSERT INTO public.auth_permission VALUES (123, 'Can add 论坛帖子', 22, 'add_forumpost'); +INSERT INTO public.auth_permission VALUES (124, 'Can change 论坛帖子', 22, 'change_forumpost'); +INSERT INTO public.auth_permission VALUES (125, 'Can delete 论坛帖子', 22, 'delete_forumpost'); +INSERT INTO public.auth_permission VALUES (126, 'Can view 论坛帖子', 22, 'view_forumpost'); +INSERT INTO public.auth_permission VALUES (127, 'Can add 论坛主题', 23, 'add_forumthread'); +INSERT INTO public.auth_permission VALUES (128, 'Can change 论坛主题', 23, 'change_forumthread'); +INSERT INTO public.auth_permission VALUES (129, 'Can delete 论坛主题', 23, 'delete_forumthread'); +INSERT INTO public.auth_permission VALUES (130, 'Can view 论坛主题', 23, 'view_forumthread'); +INSERT INTO public.auth_permission VALUES (131, 'Can add 论坛分类', 24, 'add_forumsection'); +INSERT INTO public.auth_permission VALUES (132, 'Can change 论坛分类', 24, 'change_forumsection'); +INSERT INTO public.auth_permission VALUES (133, 'Can delete 论坛分类', 24, 'delete_forumsection'); +INSERT INTO public.auth_permission VALUES (134, 'Can view 论坛分类', 24, 'view_forumsection'); +INSERT INTO public.auth_permission VALUES (135, 'Can add 论坛帖子版本', 25, 'add_forumpostversion'); +INSERT INTO public.auth_permission VALUES (136, 'Can change 论坛帖子版本', 25, 'change_forumpostversion'); +INSERT INTO public.auth_permission VALUES (137, 'Can delete 论坛帖子版本', 25, 'delete_forumpostversion'); +INSERT INTO public.auth_permission VALUES (138, 'Can view 论坛帖子版本', 25, 'view_forumpostversion'); +INSERT INTO public.auth_permission VALUES (139, 'Can add 标签分类', 26, 'add_tagscategory'); +INSERT INTO public.auth_permission VALUES (140, 'Can change 标签分类', 26, 'change_tagscategory'); +INSERT INTO public.auth_permission VALUES (141, 'Can delete 标签分类', 26, 'delete_tagscategory'); +INSERT INTO public.auth_permission VALUES (142, 'Can view 标签分类', 26, 'view_tagscategory'); +INSERT INTO public.auth_permission VALUES (143, 'Can add 操作记录', 27, 'add_actionlogentry'); +INSERT INTO public.auth_permission VALUES (144, 'Can change 操作记录', 27, 'change_actionlogentry'); +INSERT INTO public.auth_permission VALUES (145, 'Can delete 操作记录', 27, 'delete_actionlogentry'); +INSERT INTO public.auth_permission VALUES (146, 'Can view 操作记录', 27, 'view_actionlogentry'); +INSERT INTO public.auth_permission VALUES (147, 'Can add 通知', 28, 'add_usernotification'); +INSERT INTO public.auth_permission VALUES (148, 'Can change 通知', 28, 'change_usernotification'); +INSERT INTO public.auth_permission VALUES (149, 'Can delete 通知', 28, 'delete_usernotification'); +INSERT INTO public.auth_permission VALUES (150, 'Can view 通知', 28, 'view_usernotification'); +INSERT INTO public.auth_permission VALUES (151, 'Can add user notification mapping', 29, 'add_usernotificationmapping'); +INSERT INTO public.auth_permission VALUES (152, 'Can change user notification mapping', 29, 'change_usernotificationmapping'); +INSERT INTO public.auth_permission VALUES (153, 'Can delete user notification mapping', 29, 'delete_usernotificationmapping'); +INSERT INTO public.auth_permission VALUES (154, 'Can view user notification mapping', 29, 'view_usernotificationmapping'); +INSERT INTO public.auth_permission VALUES (155, 'Can add 通知订阅', 30, 'add_usernotificationsubscription'); +INSERT INTO public.auth_permission VALUES (156, 'Can change 通知订阅', 30, 'change_usernotificationsubscription'); +INSERT INTO public.auth_permission VALUES (157, 'Can delete 通知订阅', 30, 'delete_usernotificationsubscription'); +INSERT INTO public.auth_permission VALUES (158, 'Can view 通知订阅', 30, 'view_usernotificationsubscription'); +INSERT INTO public.auth_permission VALUES (159, 'Can add 文章搜索索引', 31, 'add_articlesearchindex'); +INSERT INTO public.auth_permission VALUES (160, 'Can change 文章搜索索引', 31, 'change_articlesearchindex'); +INSERT INTO public.auth_permission VALUES (161, 'Can delete 文章搜索索引', 31, 'delete_articlesearchindex'); +INSERT INTO public.auth_permission VALUES (162, 'Can view 文章搜索索引', 31, 'view_articlesearchindex'); +INSERT INTO public.auth_permission VALUES (163, 'Can add 角色分类', 32, 'add_rolecategory'); +INSERT INTO public.auth_permission VALUES (164, 'Can change 角色分类', 32, 'change_rolecategory'); +INSERT INTO public.auth_permission VALUES (165, 'Can delete 角色分类', 32, 'delete_rolecategory'); +INSERT INTO public.auth_permission VALUES (166, 'Can view 角色分类', 32, 'view_rolecategory'); +INSERT INTO public.auth_permission VALUES (167, 'Can add 角色', 33, 'add_role'); +INSERT INTO public.auth_permission VALUES (168, 'Can change 角色', 33, 'change_role'); +INSERT INTO public.auth_permission VALUES (169, 'Can delete 角色', 33, 'delete_role'); +INSERT INTO public.auth_permission VALUES (170, 'Can view 角色', 33, 'view_role'); +INSERT INTO public.auth_permission VALUES (171, 'Can add role permissions override', 34, 'add_rolepermissionsoverride'); +INSERT INTO public.auth_permission VALUES (172, 'Can change role permissions override', 34, 'change_rolepermissionsoverride'); +INSERT INTO public.auth_permission VALUES (173, 'Can delete role permissions override', 34, 'delete_rolepermissionsoverride'); +INSERT INTO public.auth_permission VALUES (174, 'Can view role permissions override', 34, 'view_rolepermissionsoverride'); +INSERT INTO public.auth_permission VALUES (175, 'Can add 私信', 35, 'add_directmessage'); +INSERT INTO public.auth_permission VALUES (176, 'Can change 私信', 35, 'change_directmessage'); +INSERT INTO public.auth_permission VALUES (177, 'Can delete 私信', 35, 'delete_directmessage'); +INSERT INTO public.auth_permission VALUES (178, 'Can view 私信', 35, 'view_directmessage'); +INSERT INTO public.auth_permission VALUES (179, 'Can add 私信拉黑', 36, 'add_directmessageblock'); +INSERT INTO public.auth_permission VALUES (180, 'Can change 私信拉黑', 36, 'change_directmessageblock'); +INSERT INTO public.auth_permission VALUES (181, 'Can delete 私信拉黑', 36, 'delete_directmessageblock'); +INSERT INTO public.auth_permission VALUES (182, 'Can view 私信拉黑', 36, 'view_directmessageblock'); +INSERT INTO public.auth_permission VALUES (183, 'Can add 用户检举', 37, 'add_userreport'); +INSERT INTO public.auth_permission VALUES (184, 'Can change 用户检举', 37, 'change_userreport'); +INSERT INTO public.auth_permission VALUES (185, 'Can delete 用户检举', 37, 'delete_userreport'); +INSERT INTO public.auth_permission VALUES (186, 'Can view 用户检举', 37, 'view_userreport'); +INSERT INTO public.auth_permission VALUES (187, 'Can add 主题', 38, 'add_theme'); +INSERT INTO public.auth_permission VALUES (188, 'Can change 主题', 38, 'change_theme'); +INSERT INTO public.auth_permission VALUES (189, 'Can delete 主题', 38, 'delete_theme'); +INSERT INTO public.auth_permission VALUES (190, 'Can view 主题', 38, 'view_theme'); +INSERT INTO public.auth_permission VALUES (191, 'Can add 系统更新', 39, 'add_systemupdate'); +INSERT INTO public.auth_permission VALUES (192, 'Can change 系统更新', 39, 'change_systemupdate'); +INSERT INTO public.auth_permission VALUES (193, 'Can delete 系统更新', 39, 'delete_systemupdate'); +INSERT INTO public.auth_permission VALUES (194, 'Can view 系统更新', 39, 'view_systemupdate'); +INSERT INTO public.auth_permission VALUES (195, 'Can add 用户提交', 40, 'add_userticket'); +INSERT INTO public.auth_permission VALUES (196, 'Can change 用户提交', 40, 'change_userticket'); +INSERT INTO public.auth_permission VALUES (197, 'Can delete 用户提交', 40, 'delete_userticket'); +INSERT INTO public.auth_permission VALUES (198, 'Can view 用户提交', 40, 'view_userticket'); +INSERT INTO public.auth_permission VALUES (199, 'Can add 申请书', 41, 'add_membershipapplication'); +INSERT INTO public.auth_permission VALUES (200, 'Can change 申请书', 41, 'change_membershipapplication'); +INSERT INTO public.auth_permission VALUES (201, 'Can delete 申请书', 41, 'delete_membershipapplication'); +INSERT INTO public.auth_permission VALUES (202, 'Can view 申请书', 41, 'view_membershipapplication'); +INSERT INTO public.auth_permission VALUES (203, 'Can add 用户工单', 42, 'add_supportticket'); +INSERT INTO public.auth_permission VALUES (204, 'Can change 用户工单', 42, 'change_supportticket'); +INSERT INTO public.auth_permission VALUES (205, 'Can delete 用户工单', 42, 'delete_supportticket'); +INSERT INTO public.auth_permission VALUES (206, 'Can view 用户工单', 42, 'view_supportticket'); +INSERT INTO public.auth_permission VALUES (207, 'Can add 邀请链接', 43, 'add_invitelink'); +INSERT INTO public.auth_permission VALUES (208, 'Can change 邀请链接', 43, 'change_invitelink'); +INSERT INTO public.auth_permission VALUES (209, 'Can delete 邀请链接', 43, 'delete_invitelink'); +INSERT INTO public.auth_permission VALUES (210, 'Can view 邀请链接', 43, 'view_invitelink'); +INSERT INTO public.auth_permission VALUES (211, 'Can add 帖子点赞', 44, 'add_forumpostlike'); +INSERT INTO public.auth_permission VALUES (212, 'Can change 帖子点赞', 44, 'change_forumpostlike'); +INSERT INTO public.auth_permission VALUES (213, 'Can delete 帖子点赞', 44, 'delete_forumpostlike'); +INSERT INTO public.auth_permission VALUES (214, 'Can view 帖子点赞', 44, 'view_forumpostlike'); +INSERT INTO public.auth_permission VALUES (215, 'Can add 收藏', 45, 'add_articlefavourite'); +INSERT INTO public.auth_permission VALUES (216, 'Can change 收藏', 45, 'change_articlefavourite'); +INSERT INTO public.auth_permission VALUES (217, 'Can delete 收藏', 45, 'delete_articlefavourite'); +INSERT INTO public.auth_permission VALUES (218, 'Can view 收藏', 45, 'view_articlefavourite'); + +INSERT INTO public.web_rolecategory VALUES (1, '用户状态'); + +INSERT INTO public.web_role VALUES (3, 'reader', '读者', '', 2, false, true, '读者投票', 'hidden', 'status', '#000000', '', '', '#808080', '#ffffff', false, 1); +INSERT INTO public.web_role VALUES (4, 'editor', '成员', '', 1, false, true, '成员投票', 'hidden', 'status', '#000000', '', '', '#808080', '#ffffff', false, 1); +INSERT INTO public.web_role VALUES (2, 'registered', '', '', 3, false, false, '', 'hidden', 'hidden', '#000000', '', '', '#808080', '#ffffff', false, NULL); +INSERT INTO public.web_role VALUES (1, 'everyone', '', '', 4, false, false, '', 'hidden', 'hidden', '#000000', '', '', '#808080', '#ffffff', false, NULL); + +INSERT INTO public.web_role_permissions VALUES (1, 1, 1); +INSERT INTO public.web_role_permissions VALUES (2, 1, 12); +INSERT INTO public.web_role_permissions VALUES (3, 1, 14); +INSERT INTO public.web_role_permissions VALUES (4, 1, 18); +INSERT INTO public.web_role_permissions VALUES (5, 1, 24); +INSERT INTO public.web_role_permissions VALUES (6, 1, 26); +INSERT INTO public.web_role_permissions VALUES (7, 3, 19); +INSERT INTO public.web_role_permissions VALUES (8, 3, 2); +INSERT INTO public.web_role_permissions VALUES (9, 3, 11); +INSERT INTO public.web_role_permissions VALUES (10, 3, 15); +INSERT INTO public.web_role_permissions VALUES (11, 4, 3); +INSERT INTO public.web_role_permissions VALUES (12, 4, 4); +INSERT INTO public.web_role_permissions VALUES (13, 4, 5); +INSERT INTO public.web_role_permissions VALUES (14, 4, 6); +INSERT INTO public.web_role_permissions VALUES (15, 4, 8); +INSERT INTO public.web_role_permissions VALUES (16, 4, 10); +INSERT INTO public.web_role_permissions VALUES (17, 4, 38); + +INSERT INTO public.web_theme VALUES (1, '默认主题', 'inline', '@charset "utf-8"; + +@import ''fonts/font-bauhaus.css''; +@import ''fonts/new-fonts.css''; +@import url(''https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap''); +@import url(''https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css''); +@import ''side-bar-redesign.css''; + +/* + Project Hana + [2026 ProjectWikit Theme] + Created for ProjectWikit by Kakushi +*/ + +:root { + + --wk-primary: #3b6fed; + --wk-primary-dark: #274bbd; + --wk-primary-darker: #1c3690; + --wk-accent: #16c6d4; + --wk-accent-dark: #0e9aa6; + + --wk-header-1: #131a2b; + --wk-header-2: #1d2b52; + --wk-header-3: #2a3f7e; + + --wk-bg: #eef1f7; + --wk-surface: #ffffff; + --wk-surface-alt: #f6f8fc; + --wk-surface-sunken: #eaeef6; + + --wk-ink: #232a36; + --wk-ink-soft: #4a5568; + --wk-ink-muted: #78849a; + --wk-on-dark: #e8edf7; + --wk-on-dark-soft: #9fb0d4; + + --wk-border: #dce2ee; + --wk-border-strong: #c2cbdd; + --wk-ring: rgba(59,111,237,.28); + --wk-shadow-sm: 0 1px 2px rgba(20,30,60,.06), 0 1px 3px rgba(20,30,60,.08); + --wk-shadow-md: 0 4px 14px rgba(20,30,60,.10); + --wk-shadow-lg: 0 12px 34px rgba(20,30,60,.16); + + --wk-beta: #8b5cf6; + --wk-ok: #12b886; + --wk-warn: #f08c00; + + --wk-radius: 10px; + --wk-radius-sm: 6px; + --wk-shell: 1240px; + --wk-sidebar-w: 17em; + + --wk-cjk: ''PingFang SC'', ''Microsoft YaHei'', ''Noto Sans SC'', + ''Hiragino Sans GB'', ''Source Han Sans SC'', ''Nanum Gothic'', sans-serif; + --wk-font: Inter, -apple-system, BlinkMacSystemFont, ''Segoe UI'', Roboto, + ''Helvetica Neue'', Arial, var(--wk-cjk); + --wk-mono: ''JetBrains Mono'', ui-monospace, SFMono-Regular, + ''SF Mono'', Menlo, Consolas, var(--wk-cjk); +} + +:root { + --new-side-bar-color: var(--wk-primary); +} + +body { + background-color: var(--wk-bg); + background-image: + radial-gradient(1100px 620px at 82% -8%, rgba(59,111,237,.10), transparent 60%), + radial-gradient(900px 520px at -6% 4%, rgba(22,198,212,.09), transparent 55%); + background-attachment: fixed; + font-family: var(--wk-font); + font-size: .82em; + color: var(--wk-ink); + font-feature-settings: ''case'', ''ss01'', ''ss04''; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +div#container-wrap { + background: none; +} + +div.class1 > div { + font-family: var(--wk-font) !important; +} + +a { + color: var(--wk-primary); + text-decoration: none; + background: transparent; + transition: color .15s ease, background-color .15s ease; +} +a:visited { color: var(--wk-primary-dark); } +a:hover { + color: var(--wk-primary-dark); + text-decoration: underline; + text-underline-offset: 2px; + background-color: transparent; +} +a.newpage { color: var(--wk-warn); } + +#side-bar a:visited { color: var(--wk-primary); } + +h1, #page-title { + color: var(--wk-ink); + padding: 0 0 .3em; + margin: 0 0 .7em; + font-weight: 800; + letter-spacing: -.01em; +} +h1 { margin-top: .7em; padding: 0; } + +h2, h3, h4, h5, h6 { + margin: 1em 0 .45em; + padding: 0; + color: var(--wk-ink); + letter-spacing: -.005em; + font-weight: 700; +} + +#page-title { + border-bottom: 1px solid var(--wk-border); + position: relative; +} + +#page-title::after { + content: ""; + position: absolute; + left: 0; + bottom: -1px; + width: 68px; + height: 3px; + border-radius: 3px; + background: linear-gradient(90deg, var(--wk-primary), var(--wk-accent)); +} + +.meta-title { + border-bottom: 1px solid var(--wk-border); + color: var(--wk-ink); + font-weight: 800; + margin: 0 0 .6em; + padding: 0 0 .25em; + font-size: 200%; +} +.meta-title p { margin: 0; } + +ul { list-style: square; } +li, p { line-height: 1.55; } + +sup { vertical-align: top; position: relative; top: -.5em; } + +.form-control { width: 95%; } + +#content-wrap { + position: relative; + margin: 2em auto 0; + max-width: var(--wk-shell); + min-height: 1300px; + height: auto !important; +} + +#header, #top-bar { + width: 100%; + max-width: var(--wk-shell); + margin: 0 auto; +} + +#header { + height: 140px; + position: relative; + z-index: 30; + padding-bottom: 22px; + margin-top: 14px; + border-radius: 0; + background: + url(''/-/static/images/wikitHana.png'') 16px 43px / 96px 96px no-repeat, + linear-gradient(120deg, var(--wk-header-1) 0%, var(--wk-header-2) 55%, var(--wk-header-3) 100%); + box-shadow: var(--wk-shadow-md); +} + +#header::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 0; + background-image: + linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px); + background-size: 26px 26px; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 40%); + mask-image: linear-gradient(90deg, transparent, #000 40%); + pointer-events: none; + opacity: .6; + z-index: 0; +} + +/* +#header::after { + content: "演示站点"; + position: absolute; + top: 48px; + right: 16px; + z-index: 5; + padding: 4px 10px; + font-family: var(--wk-mono); + font-size: 9.5px; + font-weight: 700; + letter-spacing: .14em; + white-space: nowrap; + color: var(--wk-accent); + background: rgba(11,18,38,.55); + border: 1px solid rgba(22,198,212,.45); + border-radius: 999px; + box-shadow: inset 0 0 12px rgba(22,198,212,.15); + text-shadow: 0 0 10px rgba(22,198,212,.5); +} +*/ + +#header h1, #header h2 { position: relative; z-index: 2; } + +#header h1 { + margin-left: 112px; + padding: 0; + float: left; + max-height: 95px; + font-size: large; +} +#header h1 a { + display: block; + margin: 0; + padding: 74px 0 25px; + line-height: 0; + max-height: 0; + color: #fff; + background: transparent; + font-family: ''Sans Normalcy'', var(--wk-font); + font-size: 190%; + font-weight: 800; + letter-spacing: .3px; + text-decoration: none; + text-shadow: 0 2px 12px rgba(0,0,0,.45); +} +#header h1 a:hover { text-decoration: none; } + +#header h2 { + margin-left: 112px; + padding: 0; + clear: left; + float: left; + font-size: 105%; + max-height: 38px; +} +#header h2 span { + display: block; + margin: 0; + padding: 20px 0; + line-height: 0; + max-height: 0; + font-weight: 500; + color: var(--wk-on-dark-soft); + text-shadow: 0 1px 2px rgba(0,0,0,.5); +} + +#search-top-box { + position: absolute; + top: 82px; + right: 14px; + width: 250px; + text-align: right; + z-index: 12; +} +#search-top-box-input { + border: solid 1px rgba(255,255,255,.18); + border-radius: 8px; + color: var(--wk-on-dark); + background-color: rgba(255,255,255,.07); + padding: 5px 9px; + transition: all .18s ease; +} +#search-top-box-input::placeholder { color: var(--wk-on-dark-soft); } +#search-top-box-input:hover, +#search-top-box-input:focus { + border: solid 1px var(--wk-accent); + color: #fff; + background-color: rgba(255,255,255,.12); + box-shadow: 0 0 0 3px rgba(22,198,212,.20); + outline: none; +} +#search-top-box-form input[type=submit] { + border: 0; + border-radius: 8px; + padding: 5px 12px; + font-size: 90%; + font-weight: 700; + color: #fff; + background-image: linear-gradient(180deg, var(--wk-primary), var(--wk-primary-dark)); + box-shadow: var(--wk-shadow-sm); + cursor: pointer; + transition: filter .15s ease, transform .05s ease; +} +#search-top-box-form input[type=submit]:hover, +#search-top-box-form input[type=submit]:focus { + filter: brightness(1.08); + color: #fff; +} +#search-top-box-form input[type=submit]:active { transform: translateY(1px); } + +#login-status { + color: var(--wk-on-dark-soft); + font-size: 90%; + z-index: 30; +} +#login-status a { background: transparent; color: var(--wk-on-dark); } +#login-status ul a { color: var(--wk-primary); background: transparent; } +#account-topbutton { + background: rgba(255,255,255,.14); + color: #fff; + border-radius: 5px; +} +.printuser img.small { margin-right: 1px; } + +#top-bar { + position: absolute; + top: 140px; + height: 21px; + width: 100%; + line-height: 18px; + padding: 0; + margin: 0 auto; + z-index: 20; + font-size: 90%; +} +#top-bar ul { float: right; } +#top-bar li { margin: 0; } +#top-bar a { color: #fff; background: transparent; } + +#top-bar ul li { border: 0; position: relative; } + +#top-bar ul li a { + border-left: solid 1px rgba(255,255,255,.06); + border-right: solid 1px rgba(255,255,255,.06); + text-decoration: none; + padding: 10px 12px; + line-height: 1px; + max-height: 1px; + overflow: hidden; + font-weight: 600; + letter-spacing: .01em; + transition: background-color .15s ease, color .15s ease; +} + +#top-bar ul li.sfhover a, +#top-bar ul li:hover a { + background: rgba(255,255,255,.14); + color: #fff; + border-left-color: rgba(255,255,255,.12); + border-right-color: rgba(255,255,255,.12); +} + +#top-bar ul li ul { + border: 1px solid var(--wk-border); + border-top: 0; + border-radius: 0 0 10px 10px; + box-shadow: var(--wk-shadow-lg); + width: auto; + overflow: hidden; + background: var(--wk-surface); +} +#top-bar ul li.sfhover ul li a, +#top-bar ul li:hover ul li a { + border-width: 0; + width: 160px; + border-top: 1px solid var(--wk-border); + line-height: 160%; + height: auto; + max-height: none; + padding: 3px 12px; + color: var(--wk-ink); + font-weight: 500; + background: var(--wk-surface); +} +#top-bar ul li.sfhover a:hover, +#top-bar ul li:hover a:hover { + background: var(--wk-surface-alt); + color: var(--wk-primary); + text-decoration: none; +} +#top-bar ul li ul li, +#top-bar ul li ul li.sfhover, +#top-bar ul li ul li:hover { border-width: 0; } +#top-bar ul li ul li a { border-width: 0; } +#top-bar ul li ul a, #top-bar a:hover { color: var(--wk-primary); } +.top-bar ul li:last-of-type ul { right: 0; } + +.mobile-top-bar { + display: none; + position: absolute; + left: 1em; + bottom: 0; + z-index: 0; +} + +#side-bar { + clear: none; + float: none; + position: absolute; + top: .5em; + left: 2em; + width: var(--wk-sidebar-w); + padding: 0; + border: none; + display: block; + overscroll-behavior: none; +} + +#side-bar .side-block, #interwiki .side-block { + padding: 12px 14px; + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + box-shadow: var(--wk-shadow-sm); + background: var(--wk-surface); + margin-bottom: 15px; + position: relative; + overflow: hidden; +} + +#side-bar .side-block::before, #interwiki .side-block::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--wk-primary), var(--wk-accent)); + opacity: .9; +} + +#interwiki .side-block { + width: 217px; + margin-left: 5px; + box-sizing: border-box; +} + +#side-bar .side-block.media, #interwiki .side-block.media { + background: linear-gradient(180deg, #eef7ff, var(--wk-surface)); +} +#side-bar .side-block.resources, #interwiki .side-block.resources { + background: linear-gradient(180deg, #eefcfb, var(--wk-surface)); +} + +#side-bar .side-area, #interwiki .side-area { padding: 10px; } + +#side-bar .heading, #interwiki .heading { + color: var(--wk-ink-soft); + border-bottom: 1px solid var(--wk-border); + margin: 10px 0 6px; + padding-bottom: 4px; + font-size: 8pt; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .08em; +} + +#side-bar p, #interwiki p { margin: 0; } + +#side-bar div.menu-item, #interwiki div.menu-item { + margin: 2px 0; + border-radius: var(--wk-radius-sm); +} +#side-bar div.menu-item img, #interwiki div.menu-item img { + width: 13px; height: 13px; border: 0; + margin-right: 4px; position: relative; bottom: -2px; +} +#side-bar div.menu-item a, #interwiki div.menu-item a { + font-weight: 600; + display: inline-block; +} +#side-bar div.menu-item.inactive img, #interwiki div.menu-item.inactive img { opacity: .25; } +#side-bar div.menu-item.inactive a, #interwiki div.menu-item.inactive a { color: var(--wk-ink-muted); } +#side-bar div.menu-item .sub-text, #interwiki div.menu-item .sub-text { + font-size: 80%; color: var(--wk-ink-muted); +} +#side-bar ul, #interwiki ul { list-style-type: none; padding: 0 5px 0; } + +#u-become-member { + padding: 12px; + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + box-shadow: var(--wk-shadow-sm); + background: linear-gradient(180deg, #eef3ff, var(--wk-surface)); + margin-bottom: 15px; +} + +#main-content { + margin: 0 2em 0 22em; + padding: 1.4em 1.6em; + position: relative; + background: var(--wk-surface); + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + box-shadow: var(--wk-shadow-md); + min-height: 40vh; +} + +#page-content { min-height: 800px; } + +#main-content .page-tags a[href^=''/system:page-tags/tag/_''] { display: none; } + +#breadcrumbs, +.pseudocrumbs { + margin: -.4em 0 1.2em; + font-family: var(--wk-mono); + font-size: 85%; + color: var(--wk-ink-muted); +} +#breadcrumbs a, .pseudocrumbs a { color: var(--wk-ink-soft); } + +#footer { + clear: both; + font-size: 78%; + background: linear-gradient(120deg, var(--wk-header-1), var(--wk-header-2)); + color: var(--wk-on-dark-soft); + margin-top: 18px; + padding: 10px 14px; + border-radius: 0 0 14px 14px; + box-shadow: var(--wk-shadow-md); +} +#footer .options { + visibility: visible; + display: block; + float: right; + width: 50%; + font-size: 100%; + text-align: right; +} +#footer a { color: #fff; background: transparent; } +#footer a:hover { color: var(--wk-accent); } + +.wikit-copyright-notice { + margin-top: 30px; + font-size: 11px; + color: var(--wk-ink-muted); + text-align: right; +} +.wikit-copyright-notice a { color: var(--wk-ink-muted); } + +.page-rate-widget-box { + display: inline-block; + border-radius: var(--wk-radius-sm); + box-shadow: var(--wk-shadow-sm); + margin-bottom: 10px; + margin-right: 2em; + overflow: hidden; +} +.page-rate-widget-box .rate-points { + background-color: var(--wk-primary) !important; + border: solid 1px var(--wk-primary); + border-right: 0; + border-radius: var(--wk-radius-sm) 0 0 var(--wk-radius-sm); + color: #fff; + font-weight: 700; +} +.page-rate-widget-box .rateup, +.page-rate-widget-box .ratedown { + background-color: var(--wk-surface-alt); + border-top: solid 1px var(--wk-primary); + border-bottom: solid 1px var(--wk-primary); + font-weight: bold; +} +.page-rate-widget-box .rateup a, +.page-rate-widget-box .ratedown a { + background: transparent; + color: var(--wk-primary); + padding: 0 6px; + margin: 0 1px; +} +.page-rate-widget-box .rateup a:hover, +.page-rate-widget-box .ratedown a:hover { + background: var(--wk-primary); + color: #fff; + text-decoration: none; +} +.page-rate-widget-box .cancel { + background-color: var(--wk-primary); + border: solid 1px var(--wk-primary); + border-left: 0; + border-radius: 0 var(--wk-radius-sm) var(--wk-radius-sm) 0; +} +.page-rate-widget-box .cancel a { background: transparent; text-transform: uppercase; color: rgba(255,255,255,.8); } +.page-rate-widget-box .cancel a:hover { background: var(--wk-primary-dark); color: #fff; text-decoration: none; } + +.heritage-rating-module { + float: right; + background-color: var(--wk-header-2); + padding: 2px 8px 2px 5px; + margin: 0 2em 10px 0; + border: solid 1px var(--wk-primary); + border-radius: 8px; + box-shadow: var(--wk-shadow-sm); +} +.heritage-rating-module .page-rate-widget-box { float: right; box-shadow: none; margin: 0; } +.heritage-rating-module .heritage-emblem { float: right; position: relative; top: -2px; left: 2px; height: 16px; width: 16px; overflow: visible; margin-right: 2px; } +.heritage-rating-module .heritage-emblem img { width: 20px; height: 20px; border: 0; } + +.scp-featured { display: flex; margin: 20px 0; gap: 24px; } +.scp-featured__block { + width: 50%; + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + padding: 10px 20px 14px; + background: var(--wk-surface); + box-shadow: var(--wk-shadow-sm); + position: relative; + overflow: hidden; +} +.scp-featured__block::before { + content: ""; + position: absolute; + left: 0; right: 0; top: 0; + height: 3px; + background: linear-gradient(90deg, var(--wk-primary), var(--wk-accent)); +} +.scp-featured__block:first-child { margin-right: 0; } +.scp-featured__block_type_daily { background: linear-gradient(180deg, #eefcfa, var(--wk-surface)); } +.scp-featured__block_type_gold { background: linear-gradient(180deg, #fff8e6, var(--wk-surface)); } +.scp-featured__block_type_gold::before { background: linear-gradient(90deg, #f0ac00, #ffd43b); } + +.scp-featured__title { + font-size: 12px; + color: var(--wk-ink-muted); + text-transform: uppercase; + letter-spacing: .8px; + font-weight: 800; +} +.scp-featured__title p { margin-bottom: 0; } +.scp-featured__title_type_secondary { margin-top: 25px; } +.scp-featured__page-title a { font-weight: 700; font-size: 14px; } +.scp-featured__content p { font-size: 13px; font-style: italic; color: var(--wk-ink-soft); } +.scp-featured__previous { color: var(--wk-ink-muted); font-size: 12px; margin-top: 25px; } +.scp-featured__previous-title { margin-bottom: 4px; display: inline-block; } +.scp-featured__block_type_gold a { font-weight: normal; font-size: 1em; } + +.scp-other-branches { + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + padding: 8px 20px 18px; + background: var(--wk-surface); + text-align: center; + box-shadow: var(--wk-shadow-sm); +} + +.welcome-warning { text-align: center; font-size: 16px; line-height: 1.25; } + +div.scpnet-interwiki-wrapper { width: 17em; margin-left: -5px; } +iframe.scpnet-interwiki-frame { height: 400px; width: 17em; border: none; } +@media (min-width: 768px) { + iframe.scpnet-interwiki-frame, div.scpnet-interwiki-wrapper { width: 18em; } +} + +.content-panel { + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + background-color: var(--wk-surface); + margin: 10px 0 15px; + box-shadow: var(--wk-shadow-sm); + overflow: hidden; +} +.content-panel.standalone { background: var(--wk-surface); } +.content-panel.series { padding: 0 20px; margin-bottom: 20px; } +.content-panel.centered { text-align: center; } +.content-panel.left-column { float: left; width: 48%; } +.content-panel.right-column { float: right; width: 48%; } + +.content-panel .panel-heading { + padding: 8px 14px; + color: #fff; + font-size: 90%; + font-weight: 700; + background: linear-gradient(120deg, var(--wk-primary), var(--wk-primary-dark)); + text-shadow: none; +} +.content-panel .panel-heading > p, +.content-panel .panel-footer > p { margin: 0; } +.content-panel .panel-body { + padding: 10px 14px; + background: var(--wk-surface); +} +.content-panel .panel-footer { + padding: 4px 14px; + color: var(--wk-ink-muted); + font-size: 80%; + font-weight: 600; + text-align: right; + background: var(--wk-surface-alt); + border-top: 1px solid var(--wk-border); + text-shadow: none; +} +.content-panel .panel-footer a { color: var(--wk-primary); } +.content-panel .content-toc { + float: right; + padding: 0 20px; + background-color: var(--wk-surface); + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + margin: 20px 0 5px 5px; + white-space: nowrap; + box-shadow: var(--wk-shadow-sm); +} +.alternate:nth-child(even) { background-color: var(--wk-surface-alt); } + +div.sexy-box { + background: var(--wk-surface-alt); + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + padding: 0 12px 12px; + margin: 7px 4px 12px; + overflow: hidden; +} +div.sexy-box div.image-container img { + margin: 5px; padding: 2px; + border: 1px solid var(--wk-border-strong); + border-radius: var(--wk-radius-sm); +} + +.unmargined > p { margin: 0; line-height: 1; } + +.wk-image-block, +.scp-image-block { + border: 1px solid var(--wk-border); + border-radius: var(--wk-radius); + box-shadow: var(--wk-shadow-sm); + width: 300px; + overflow: hidden; + background: var(--wk-surface); +} + +.wk-image-block.block-right, +.scp-image-block.block-right { + float: right; + clear: right; + margin: 0 0 1em 2em; +} + +.wk-image-block.block-left, +.scp-image-block.block-left { + float: left; + clear: left; + margin: 0 2em 1em 0; +} + +.wk-image-block.block-center, +.scp-image-block.block-center { + margin-left: auto; + margin-right: auto; +} + +.wk-image-block img, +.scp-image-block img { + border: 0; + width: 100%; + display: block; +} + +.wk-image-block .wk-image-caption, +.scp-image-block .scp-image-caption { + background-color: var(--wk-surface-alt); + border-top: 1px solid var(--wk-border); + padding: 5px 0; + font-size: 80%; + font-weight: 600; + color: var(--wk-ink-soft); + text-align: center; + width: 100%; +} + +.wk-image-block > p, +.scp-image-block > p { + margin: 0; +} + +.wk-image-block .wk-image-caption > p, +.scp-image-block .scp-image-caption > p { + margin: 0; + padding: 0 10px; +} + +.rimg { float: right; margin: 10px auto 5px 8px; } +.limg { float: left; margin: 10px 8px 5px auto; } +.cimg { margin: 10px auto 5px auto; width: 600px; } +.rimg, .limg, .cimg { + border: 1px solid var(--wk-border-strong); + border-radius: var(--wk-radius-sm); + text-align: center; + font-size: 8pt; + font-weight: bold; + background: var(--wk-surface-alt); + box-shadow: var(--wk-shadow-sm); + overflow: hidden; +} +.rimg img, .limg img, .cimg img { border: none; } +.rimg span, .limg span, .cimg span { + display: block; + border-top: 1px solid var(--wk-border); + padding: 3px 0; +} +.rimg span > span, .limg span > span, .cimg span > span { display: inline; margin: 0; } + +.hover span { display: none; } +.hover:hover span { + position: relative; bottom: 25px; right: 75px; + display: inline; margin: auto; height: auto; width: auto; + background: var(--wk-surface); border: 1px solid var(--wk-border-strong); + color: var(--wk-ink-soft); padding: 1em; font-size: 12px; + border-radius: var(--wk-radius-sm); box-shadow: var(--wk-shadow-md); +} +.hover:hover span span { position: relative; margin: auto; height: auto; width: auto; border: none; padding: 0; } + +#main-content .page-tags { margin: 1.4em 0 0; padding: .8em 0 0; border-top: 1px solid var(--wk-border); } +#main-content .page-tags span { display: inline-block; padding: 0; max-width: 60%; } +#main-content .page-tags a { display: inline-block; white-space: nowrap; } +#main-content .page-tags a[href^="/system:page-tags/tag/_"] { display: none; } + +.tags { + display: inline-block; + margin: 0 4px 4px 0; + padding: 2px 9px; + line-height: 15px; + font-size: 11px; + font-family: var(--wk-mono); + background: var(--wk-surface-sunken); + color: var(--wk-ink-soft); + border: 1px solid var(--wk-border); + text-decoration: none; + border-radius: 999px; + transition: all .15s ease; +} +.tags:hover { background: var(--wk-primary); color: #fff; border-color: var(--wk-primary); text-decoration: none; } +.tags::before, .tags::after { content: none; } + +.forum-thread-box .description-block { + padding: .6em 1em; + border-radius: var(--wk-radius); + background: var(--wk-surface); + border: 1px solid var(--wk-border); + box-shadow: var(--wk-shadow-sm); +} +.thread-container .post .head { + padding: .5em 1em; + background: var(--wk-surface-alt); + box-shadow: none; + border: 1px solid var(--wk-border); + border-bottom: 0; + border-radius: var(--wk-radius) var(--wk-radius) 0 0; +} +.thread-container .post .long .head .title { word-break: break-all; font-weight: 700; } +.thread-container .post .long .head.op-post { background: linear-gradient(120deg, #eef3ff, #eefcfb); } +.thread-container .post .long .head .vote { flex-grow: 1; text-align: right; color: var(--wk-ink-muted); } +.thread-container .post .long .head .rate { font-weight: bold; } +.thread-container .post .long .head .rate::before { + font-family: "Font Awesome 5 Free"; content: "\f005"; letter-spacing: 2px; color: var(--wk-warn); +} +.post-container .post-container { border-left: 2px solid var(--wk-border); padding-left: 1rem; } + +.signature { display: none !important; } + +.yui-navset .yui-content { + background-color: var(--wk-surface); + border: 1px solid var(--wk-border); + border-radius: 0 var(--wk-radius) var(--wk-radius) var(--wk-radius); +} +.yui-navset .yui-nav, +.yui-navset .yui-navset-top .yui-nav { border-color: var(--wk-primary); } +.yui-navset .yui-nav a, +.yui-navset .yui-navset-top .yui-nav a { + background-color: var(--wk-surface-alt); + background-image: none; + border: 1px solid var(--wk-border); + border-bottom: 0; + color: var(--wk-ink-soft); + border-radius: var(--wk-radius-sm) var(--wk-radius-sm) 0 0; +} +.yui-navset .yui-nav .selected a, +.yui-navset .yui-nav .selected a:focus, +.yui-navset .yui-nav .selected a:hover { + background: var(--wk-primary); + background-image: none; + color: #fff; +} +.yui-navset .yui-nav a:hover, +.yui-navset .yui-nav a:focus { + background: var(--wk-surface-sunken); + background-image: none; + color: var(--wk-primary); + text-decoration: none; +} +.yui-navset li { line-height: normal; } + +blockquote, +div.blockquote { + border: 1px solid var(--wk-border); + border-left: 3px solid var(--wk-primary); + background-color: var(--wk-surface-alt); + padding: .4em 1em; + margin: 1em 3em; + border-radius: var(--wk-radius-sm); +} +div.curved { border-radius: var(--wk-radius); margin: 1em 3em; } + +@media (max-width: 479px) { div.blockquote, div.curved { margin: 1em 0; } } +@media (min-width: 480px) and (max-width: 580px) { div.blockquote, div.curved { margin: .5em; } } + +.keycap { + border: 1px solid var(--wk-border-strong); + border-bottom-width: 2px; + border-radius: 4px; + background-color: var(--wk-surface-alt); + padding: 1px 5px; + font-family: var(--wk-mono); + font-size: .85em; + white-space: nowrap; + box-shadow: 0 1px 0 var(--wk-border-strong); +} + +.ruby, ruby { display: inline-table; text-align: center; white-space: nowrap; line-height: 1; height: 1em; vertical-align: text-bottom; } +.rt, rt { display: table-header-group; font-size: .6em; line-height: 1.1; text-align: center; white-space: nowrap; } + +.bblock { color: #000; background-color: #000; transition: 2s; text-decoration: none; } +.bblock:hover { background-color: #000; color: var(--wk-ok); text-decoration: none; } +.dblock { color: #000; background-color: #000; transition: 2s; text-decoration: none; } +.dblock:hover { background-color: transparent; text-decoration: none; } + +.emph { text-emphasis-style: dot; -webkit-text-emphasis-style: dot; } +@-moz-document url-prefix() { + .emph { font-family: monospace; font-style: normal; font-weight: normal; background-repeat: repeat-x; padding: .5em 0 0; background-color: transparent; background-clip: padding-box, content-box; background-size: 1em 1.3em, auto; } +} + +.footer-wikiwalk-nav { font-weight: 700; font-size: 90%; } + +.licensebox .collapsible-block-link { + margin-left: .25em; padding: .25em; font-weight: bold; opacity: .5; color: inherit; + transition: opacity .5s ease-in-out; +} +.licensebox .collapsible-block-link:hover, +.licensebox .collapsible-block-link:active { opacity: 1; } + +.ny2017-link { text-align: center; } +.ny2017-link a { + color: #fff; margin-bottom: 15px; display: block; font-size: 13px; + background: var(--wk-ok); padding: 4px; font-weight: normal !important; border-radius: var(--wk-radius-sm); +} + +.scpnet-progress-bar { height: 17px; width: 100%; background: var(--wk-surface-sunken); border-radius: 999px; overflow: hidden; } +.scpnet-progress-bar__tick { animation: scpnet-progress-bar 4s linear; background: linear-gradient(90deg, var(--wk-primary), var(--wk-accent)); height: 100%; } +.scpnet-progress-bar_type_fast .scpnet-progress-bar__tick { animation-duration: 2s; } +.scpnet-delayed-revealing { animation: scpnet-delayed-revealing 4.4s linear; } +.scpnet-delayed-revealing_type_fast { animation-duration: 2.2s; } + +@keyframes scpnet-progress-bar { + 0% { width: 0; } 10% { width: 10%; } 20% { width: 20%; } 30% { width: 30%; } + 40% { width: 40%; } 50% { width: 50%; } 60% { width: 60%; } 70% { width: 70%; } + 80% { width: 80%; } 90% { width: 90%; } 100% { width: 100%; } +} +@keyframes scpnet-delayed-revealing { + 0% { visibility: hidden; opacity: 0; } + 97% { visibility: visible; opacity: 0; } + 100% { visibility: visible; opacity: 1; } +} + +div#u-adult-warning { + width: fit-content; + margin: 0 auto 20px; + padding: .6rem 1.2rem; + border: 2px solid var(--wk-warn); + border-radius: var(--wk-radius); + background: #fff8ee; + color: var(--wk-ink); + text-align: center; + font-weight: bold; +} +div#u-adult-warning > div#u-adult-header { font-size: 300%; color: var(--wk-warn); text-shadow: none; } +div#u-adult-warning > div#u-adult-header p { margin: 0; } +div#u-adult-warning > .error-block { color: unset; padding: unset; margin: unset; border: unset; margin-bottom: 1em; } + +.changes-list-item td.title { min-width: 40%; } +.changes-list-item .flags { text-align: center; width: 2em; } +.changes-list-item .mod-date { text-align: center; } +.changes-list-item .mod-by { width: 10em; } +@media (max-width: 435px) { .changes-list-item .revision-no { display: none; } } + +.w-user-mention { color: var(--wk-primary) !important; background: rgba(59,111,237,.09); border-radius: 3px; } + +#odialog-shader-iframe, #odialog-shader { pointer-events: none; } + +div.preview { display: none; } +.page-source { word-break: break-all; } + +img, embed, video, object, iframe, table { max-width: 100%; } +#page-content div, #page-content div table { max-width: 100%; } +#edit-page-comments { width: 100%; } + +@viewport { width: device-width; zoom: 1; } +@-ms-viewport { width: device-width; zoom: 1; } +@-o-viewport { width: device-width; zoom: 1; } + +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-track { background: var(--wk-surface-sunken); } +::-webkit-scrollbar-thumb { background: var(--wk-border-strong); border-radius: 999px; border: 2px solid var(--wk-surface-sunken); } +::-webkit-scrollbar-thumb:hover { background: var(--wk-primary); } + +@media (max-width: 767px) { + td, th { word-break: break-all; } + .owindow { min-width: 80%; max-width: 99%; } + .modal-body .table, .modal-body .table ~ div { float: left; } + .owindow .button-bar { float: right; } + .owindow div a.btn-primary { width: 100%; float: left; } + .mobile-top-bar ul li:last-of-type ul { right: 0; } + #page-content .pages-list, #page-content .pages-list div { clear: none; } + .scp-featured { display: block; } + .scp-featured__block { width: auto; } + .scp-featured__block:first-child { margin: 20px 0; } +} + +@media (max-width: 479px) { + ul li ul li a { font-size: 150%; } + .mobile-top-bar { display: block; padding: 0; font-size: 58%; } + #header, .mobile-top-bar { max-width: 100%; } + #top-bar ul { float: left; padding-left: 10px; } + #search-top-box-input { display: none; } + #page-content { font-size: .9em; } + #main-content { margin: 0; border-radius: var(--wk-radius); } + #recent-posts-category { width: 100%; } + #header, .mobile-top-bar { max-width: 90%; margin: auto; } + #side-bar { width: 80%; position: relative; } + .top-bar { display: none; } + .page-options-bottom a { padding: 0 4px; } + #header h1 a { font-size: 150%; } + blockquote { margin: 1em 0; } + .license-area { font-size: .8em; } + #header { background-position: 14px 5.4em, 0 0; background-size: 52px 52px, cover; } + #header h1, #header h2 { margin-left: 78px; } + #header::after { font-size: 8px; letter-spacing: .08em; } + table.form td, table.form th { float: left; padding: 0; } + td.name { width: 15em; } + #edit-page-title { width: 90%; } + .content-panel.left-column, .content-panel.right-column { width: 99%; float: left; } + #page-content div, #page-content div table { clear: both; } + #page-content div.title { word-break: keep-all; } +} + +@media (max-width: 385px) { + ul li ul li a { font-size: 150%; } + .mobile-top-bar { display: block; padding: 0; font-size: 58%; } + #header, .mobile-top-bar { max-width: 100%; } + #header h2 { font-size: 95%; } + #top-bar ul { float: left; padding-left: 10px; } + #header { background-position: 5% 5.4em, 0 0; } + #header h1, #header h2 { margin-left: calc(66px + 5%); } + #header, #top-bar { width: 100%; max-width: 100%; } + .mobile-top-bar { width: 100%; } + #top-bar li a { padding: 10px .5em; } +} + +@media (min-width: 480px) and (max-width: 580px) { + ul li ul li a { font-size: 150%; } + .mobile-top-bar { display: block; padding: 0; font-size: 58%; } + #header, .mobile-top-bar { max-width: 100%; } + #top-bar ul { float: left; padding-left: 10px; } + #search-top-box-input { width: 7em; } + #main-content { margin: 0 2em; } + #header, .mobile-top-bar { max-width: 90%; } + #side-bar { width: 80%; position: relative; } + .top-bar { display: none; } + .mobile-top-bar { display: block; } + .page-options-bottom a { padding: 0 5px; } + #header h1 a { font-size: 170%; } + blockquote { margin: .5em; } + .license-area { font-size: .85em; } + #header { background-position: .6em 4.4em, 0 0; background-size: 64px 64px, cover; } + #header h1, #header h2 { margin-left: 90px; } + #page-content div.title { word-break: keep-all; } + td.name { width: 15em; } + .content-panel.left-column, .content-panel.right-column { width: 99%; float: left; } + #page-content div, #page-content div table { clear: both; } +} + +@media (min-width: 581px) and (max-width: 767px) { + ul li ul li a { font-size: 150%; } + .mobile-top-bar { display: block; padding: 0; font-size: 58%; } + #header, .mobile-top-bar { max-width: 100%; } + #top-bar ul { float: left; padding-left: 10px; } + #search-top-box { top: 108px; } + #search-top-box-input { width: 8em; } + #side-bar { width: 80%; position: relative; } + #main-content { margin: 0 3em 0 2em; } + #header, .mobile-top-bar { max-width: 90%; } + .top-bar { display: none; } + .mobile-top-bar { display: block; } + .page-options-bottom a { padding: 0 6px; } + #header h1 a { font-size: 180%; } + .license-area { font-size: .9em; } + #header { background-position: 1em 3.8em, 0 0; background-size: 74px 74px, cover; } + #header h1, #header h2 { margin-left: 100px; } + td { word-break: break-all; } +} + +@media (min-width: 768px) and (max-width: 979px) { + #main-content { margin: 0 4em 0 20em; } + #header, #top-bar #side-bar { max-width: 100%; } + .top-bar li { margin: 0; } + #top-bar ul li.sfhover ul li a, + #top-bar ul li:hover ul li a { width: 130px; } + .page-options-bottom a { padding: 0 7px; } + #header h1 a { font-size: 200%; } + .license-area { font-size: .95em; } + #header { background-position: 1em 3.6em, 0 0; background-size: 84px 84px, cover; } + #header h1, #header h2 { margin-left: 112px; } + .content-panel.left-column, .content-panel.right-column { width: 99%; float: left; } + #page-content div, #page-content div table { clear: both; } + #page-content .pages-list, #page-content .pages-list div { clear: none; } +} + +.close-menu { display: none; } + +@media (max-width: 767px) { + ul li ul li a { font-size: 150%; } + .mobile-top-bar { display: block; padding: 0; font-size: 58%; } + #header, .mobile-top-bar { max-width: 100%; } + #top-bar ul { float: left; padding-left: 10px; } + .page-history tbody tr td:last-child { width: 35%; } + .owindow { min-width: 80%; max-width: 99%; } + .modal-body .table, .modal-body .table ~ div { float: left; } + .owindow .button-bar { float: right; } + .owindow div .btn-primary { width: 100%; float: left; } + .owindow div .btn-primary ~ div { width: 100%; } + .yui-navset { z-index: 1; } + #navi-bar, #navi-bar-shadow { display: none; } + #header::after { + top: auto; + bottom: 10px; + right: 12px; + font-size: 8px; + letter-spacing: .06em; + padding: 3px 8px; + z-index: 6; + } + + #top-bar .open-menu a { + position: fixed; + top: .6em; + left: .6em; + z-index: 15; + font-family: var(--wk-font); + font-size: 26px; + font-weight: 700; + width: 40px; + height: 40px; + line-height: 38px; + text-align: center; + border: 0; + background: linear-gradient(120deg, var(--wk-primary), var(--wk-primary-dark)); + border-radius: 12px; + color: #fff; + box-shadow: var(--wk-shadow-md); + } + #top-bar .open-menu a:hover { + text-decoration: none; + box-shadow: 0 0 0 4px var(--wk-ring), var(--wk-shadow-md); + } + + #main-content { + max-width: 90%; + margin: 0 5%; + padding: 1em; + transition: .5s ease-in-out .1s; + } + + #side-bar { + display: block; + position: fixed; + top: 0; + left: -25em; + width: 17em; + height: 100%; + background-color: var(--wk-surface); + border-right: 1px solid var(--wk-border); + box-shadow: var(--wk-shadow-lg); + overflow-y: auto; + z-index: 60; + padding: 1em 1em 0; + transition: left .5s ease-in-out .1s; + } + #side-bar::after { + content: ""; + position: absolute; + top: 0; + width: 0; + height: 100%; + background-color: rgba(0,0,0,.2); + } + #header h2 { font-size: 65%; } + + #side-bar:target { + display: block; + left: 0; + width: 17em; + margin: 0; + z-index: 60; + } + #side-bar:target + #main-content { left: 0; } + #side-bar:target .close-menu { + display: block; + position: fixed; + width: 100%; + height: 100%; + top: 0; + left: 0; + background: rgba(10,15,30,.45); + z-index: -1; + } +} +', '', '2026-09-06 02:52:57.277747+00', 'default'); + +SELECT pg_catalog.setval('public.auth_permission_id_seq', 218, true); + +SELECT pg_catalog.setval('public.django_content_type_id_seq', 45, true); + +SELECT pg_catalog.setval('public.web_role_id_seq', 4, true); + +SELECT pg_catalog.setval('public.web_role_permissions_id_seq', 17, true); + +SELECT pg_catalog.setval('public.web_rolecategory_id_seq', 1, true); + +SELECT pg_catalog.setval('public.web_theme_id_seq', 1, true); + diff --git a/internal/migrate/sql/0002_admin_log_and_addresses.sql b/internal/migrate/sql/0002_admin_log_and_addresses.sql new file mode 100644 index 00000000..68d5e725 --- /dev/null +++ b/internal/migrate/sql/0002_admin_log_and_addresses.sql @@ -0,0 +1,29 @@ +-- compat: compatible +-- The addresses an account has been seen at, one row per pair rather than one +-- per action, so the table stays small enough to read whole. +CREATE TABLE pwikit_user_address ( + user_id bigint NOT NULL REFERENCES web_user (id) ON DELETE CASCADE, + address inet NOT NULL, + first_seen timestamptz NOT NULL, + last_seen timestamptz NOT NULL, + hits integer NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, address) +); + +-- Answering "who else came from here" is the whole point of the table. +CREATE INDEX pwikit_user_address_address_idx ON pwikit_user_address (address); + +-- What staff did in the admin. Nothing else writes here, so it stays small and +-- is kept rather than pruned. +CREATE TABLE pwikit_admin_log ( + id bigserial PRIMARY KEY, + user_id bigint REFERENCES web_user (id) ON DELETE SET NULL, + stale_name text NOT NULL DEFAULT '', + action text NOT NULL, + screen text NOT NULL, + target text NOT NULL DEFAULT '', + label text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL +); + +CREATE INDEX pwikit_admin_log_created_idx ON pwikit_admin_log (created_at DESC, id DESC); diff --git a/internal/migrate/sql/0003_site_columns.sql b/internal/migrate/sql/0003_site_columns.sql new file mode 100644 index 00000000..9a8ad8a3 --- /dev/null +++ b/internal/migrate/sql/0003_site_columns.sql @@ -0,0 +1,73 @@ +-- compat: breaking +ALTER TABLE web_user ADD COLUMN wikidot_user_id bigint; + +-- The archive keys every author and voter by this number while usernames drift, +-- so it is the only stable way to recognise someone across two backups. +CREATE UNIQUE INDEX web_user_wikidot_user_id_key ON web_user (wikidot_user_id); + +ALTER TABLE web_article ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_category ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_tag ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_tagscategory ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_role ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_rolecategory ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_forumsection ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_theme ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_invitelink ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_userreport ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE web_userticket ADD COLUMN site_id bigint REFERENCES web_site (id); +ALTER TABLE pwikit_admin_log ADD COLUMN site_id bigint REFERENCES web_site (id); + +-- A comment thread hangs off an article and a forum thread off a category, so +-- without a column of its own the site takes a two-way branch to answer. +ALTER TABLE web_forumthread ADD COLUMN site_id bigint REFERENCES web_site (id); + +DO $$ +DECLARE + only_site bigint; + one text; +BEGIN + SELECT id INTO only_site FROM web_site ORDER BY id LIMIT 1; + IF only_site IS NULL THEN + RETURN; + END IF; + FOREACH one IN ARRAY ARRAY[ + 'web_article', 'web_category', 'web_tag', 'web_tagscategory', 'web_role', + 'web_rolecategory', 'web_forumsection', 'web_forumthread', 'web_theme', + 'web_invitelink', 'web_userreport', 'web_userticket', 'pwikit_admin_log' + ] LOOP + EXECUTE format('UPDATE %I SET site_id = %s', one, only_site); + END LOOP; +END $$; + +CREATE INDEX web_article_site_idx ON web_article (site_id); +CREATE INDEX web_forumthread_site_idx ON web_forumthread (site_id); + +ALTER TABLE web_article DROP CONSTRAINT web_article_unique; +ALTER TABLE web_article ADD CONSTRAINT web_article_unique UNIQUE (site_id, category, name); + +ALTER TABLE web_category DROP CONSTRAINT web_category_unique; +ALTER TABLE web_category ADD CONSTRAINT web_category_unique UNIQUE (site_id, name); + +ALTER TABLE web_role DROP CONSTRAINT web_role_slug_key; +ALTER TABLE web_role ADD CONSTRAINT web_role_slug_key UNIQUE (site_id, slug); + +ALTER TABLE web_tag DROP CONSTRAINT web_tag_unique; +ALTER TABLE web_tag ADD CONSTRAINT web_tag_unique UNIQUE (site_id, category_id, name); + +-- Two constraints said the same thing about the slug, and only one of them +-- comes back. +ALTER TABLE web_tagscategory DROP CONSTRAINT web_tagscategory_unique; +ALTER TABLE web_tagscategory DROP CONSTRAINT web_tagscategory_slug_key; +ALTER TABLE web_tagscategory ADD CONSTRAINT web_tagscategory_unique UNIQUE (site_id, slug); + +ALTER TABLE web_tagscategory DROP CONSTRAINT web_tagscategory_priority_fd2df012_uniq; +ALTER TABLE web_tagscategory ADD CONSTRAINT web_tagscategory_priority_fd2df012_uniq UNIQUE (site_id, priority); + +ALTER TABLE web_theme DROP CONSTRAINT web_theme_slug_7893de0e_uniq; +ALTER TABLE web_theme ADD CONSTRAINT web_theme_slug_7893de0e_uniq UNIQUE (site_id, slug); + +-- Host routing reads both columns as one namespace. Without this a second site +-- can claim the first site's media domain, which puts uploaded HTML on an +-- origin that is not its own. +ALTER TABLE web_site ADD CONSTRAINT web_site_media_domain_unique UNIQUE (media_domain); diff --git a/internal/migrate/sql/0004_link_sites.sql b/internal/migrate/sql/0004_link_sites.sql new file mode 100644 index 00000000..d40c7fe5 --- /dev/null +++ b/internal/migrate/sql/0004_link_sites.sql @@ -0,0 +1,20 @@ +-- compat: breaking +ALTER TABLE web_externallink ADD COLUMN from_site_id bigint REFERENCES web_site (id); + +-- An include may name a page on another site, so the two ends of a reference do +-- not have to agree on the site. +ALTER TABLE web_externallink ADD COLUMN to_site_id bigint REFERENCES web_site (id); + +DO $$ +DECLARE + only_site bigint; +BEGIN + SELECT id INTO only_site FROM web_site ORDER BY id LIMIT 1; + IF only_site IS NULL THEN + RETURN; + END IF; + UPDATE web_externallink SET from_site_id = only_site, to_site_id = only_site; +END $$; + +CREATE INDEX web_externallink_to_idx ON web_externallink (to_site_id, link_to); +CREATE INDEX web_externallink_from_idx ON web_externallink (from_site_id, link_from); diff --git a/internal/migrate/sql/0005_language.sql b/internal/migrate/sql/0005_language.sql new file mode 100644 index 00000000..15e94ada --- /dev/null +++ b/internal/migrate/sql/0005_language.sql @@ -0,0 +1,5 @@ +-- compat: compatible +ALTER TABLE web_site ADD COLUMN language text NOT NULL DEFAULT 'zh-hans'; + +-- Empty says the member never chose, which is what leaves the browser a say. +ALTER TABLE web_user ADD COLUMN language text NOT NULL DEFAULT ''; diff --git a/internal/migrate/sql/0006_search_exclusions.sql b/internal/migrate/sql/0006_search_exclusions.sql new file mode 100644 index 00000000..6590760b --- /dev/null +++ b/internal/migrate/sql/0006_search_exclusions.sql @@ -0,0 +1,5 @@ +-- compat: breaking +-- web_category already carried this flag and the admin already offered it, but +-- nothing read it. A tag and a page get the same switch. +ALTER TABLE web_tag ADD COLUMN is_indexed boolean NOT NULL DEFAULT true; +ALTER TABLE web_article ADD COLUMN is_indexed boolean NOT NULL DEFAULT true; diff --git a/internal/migrate/sql/0007_site_time_zone.sql b/internal/migrate/sql/0007_site_time_zone.sql new file mode 100644 index 00000000..26f50bb3 --- /dev/null +++ b/internal/migrate/sql/0007_site_time_zone.sql @@ -0,0 +1,4 @@ +-- compat: compatible +-- UTC until somebody picks one, since the zone of the machine says nothing about +-- where the readers of a site are. +ALTER TABLE web_site ADD COLUMN time_zone text NOT NULL DEFAULT 'UTC'; diff --git a/internal/migrate/sql/0008_member_sanctions.sql b/internal/migrate/sql/0008_member_sanctions.sql new file mode 100644 index 00000000..d9e80da4 --- /dev/null +++ b/internal/migrate/sql/0008_member_sanctions.sql @@ -0,0 +1,50 @@ +-- compat: breaking +-- What a site has done to one of its members. Accounts are shared by every +-- site of an instance, so a sanction that belongs to one site cannot live on +-- the account row. +CREATE TABLE pwikit_member_sanction ( + id bigserial PRIMARY KEY, + site_id bigint NOT NULL REFERENCES web_site (id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES web_user (id) ON DELETE CASCADE, + kind text NOT NULL, + -- Null lasts until someone lifts it. + until timestamptz, + reason text NOT NULL DEFAULT '', + set_by_id bigint REFERENCES web_user (id) ON DELETE SET NULL, + set_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (site_id, user_id, kind) +); + +CREATE INDEX pwikit_member_sanction_site_idx ON pwikit_member_sanction (site_id, until); + +INSERT INTO auth_permission (id, name, content_type_id, codename) +SELECT (SELECT max(id) FROM auth_permission) + row_number() OVER (ORDER BY wanted.codename), + '', content.id, wanted.codename +FROM (VALUES + ('ban_members'), + ('mute_members'), + ('restrict_member_editing'), + ('restrict_member_rating'), + ('reset_member_votes'), + ('invite_members'), + ('manage_bots') +) AS wanted (codename) +CROSS JOIN ( + SELECT id FROM django_content_type WHERE app_label = 'web' AND model = 'roles' +) AS content +WHERE NOT EXISTS (SELECT 1 FROM auth_permission p WHERE p.codename = wanted.codename); + +SELECT setval(pg_get_serial_sequence('auth_permission', 'id'), (SELECT max(id) FROM auth_permission)); + +-- Whoever managed members before this split keeps everything the split created, +-- so an upgrade takes nothing away. +INSERT INTO web_role_permissions (role_id, permission_id) +SELECT held.role_id, added.id +FROM web_role_permissions held +JOIN auth_permission manage ON manage.id = held.permission_id AND manage.codename = 'manage_users' +CROSS JOIN auth_permission added +WHERE added.codename IN ('ban_members', 'mute_members', 'restrict_member_editing', + 'restrict_member_rating', 'reset_member_votes', 'invite_members', 'manage_bots') + AND NOT EXISTS ( + SELECT 1 FROM web_role_permissions have + WHERE have.role_id = held.role_id AND have.permission_id = added.id); diff --git a/internal/migrate/sql/0009_explicit_content_settings.sql b/internal/migrate/sql/0009_explicit_content_settings.sql new file mode 100644 index 00000000..e16342ea --- /dev/null +++ b/internal/migrate/sql/0009_explicit_content_settings.sql @@ -0,0 +1,9 @@ +-- compat: compatible +-- A site has nothing above it to follow, so its own rows name a value. The +-- values written here are the ones the code fell back to. Categories keep +-- following the site, which is still an answer they can give. +UPDATE web_settings SET rating_mode = 'updown' +WHERE site_id IS NOT NULL AND rating_mode = 'default'; + +UPDATE web_settings SET can_user_create_tags = 'disabled' +WHERE site_id IS NOT NULL AND can_user_create_tags = 'default'; diff --git a/internal/migrate/sql/0010_update_state.sql b/internal/migrate/sql/0010_update_state.sql new file mode 100644 index 00000000..13553a1a --- /dev/null +++ b/internal/migrate/sql/0010_update_state.sql @@ -0,0 +1,27 @@ +-- compat: compatible +CREATE TABLE pwikit_update ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + checked_at timestamptz, + check_error text NOT NULL DEFAULT '', + next_check_at timestamptz, + latest_version text NOT NULL DEFAULT '', + latest_published_at timestamptz, + latest_postgres text NOT NULL DEFAULT '', + latest_notes text NOT NULL DEFAULT '', + scheduled_version text NOT NULL DEFAULT '', + scheduled_at timestamptz, + postponed_until timestamptz, + skipped_version text NOT NULL DEFAULT '', + failed_versions text[] NOT NULL DEFAULT '{}', + pinned_version text NOT NULL DEFAULT '', + last_from text NOT NULL DEFAULT '', + last_to text NOT NULL DEFAULT '', + last_outcome text NOT NULL DEFAULT '', + last_error text NOT NULL DEFAULT '', + last_at timestamptz, + rollback_version text NOT NULL DEFAULT '', + rollback_kind text NOT NULL DEFAULT '', + rollback_expires_at timestamptz +); + +INSERT INTO pwikit_update (id) VALUES (1); diff --git a/internal/migrate/testdata/fixture.sql b/internal/migrate/testdata/fixture.sql new file mode 100644 index 00000000..32a67272 --- /dev/null +++ b/internal/migrate/testdata/fixture.sql @@ -0,0 +1,6578 @@ +INSERT INTO public.web_user VALUES (1, 'pbkdf2_sha256$1000000$BCEp0IYLIrpb$UDF4z3zPqN7ji9u9HfJJOCtlGJmOFWT/cCF4CZ+KjWc=', '2026-09-05 11:58:21.332836+00', false, '', '', '', '2026-08-20 07:26:18.01709+00', 'seeduser', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (30, '', NULL, false, '', '', '', '2026-08-24 10:49:07.504795+00', 'probe-author', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, 'Probe Author', NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (32, '', NULL, false, '', '', '', '2026-08-24 10:49:07.53524+00', 'probevoter', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (188, '', NULL, true, '', '', '', '2026-08-30 04:50:27.310595+00', 'probe-staff', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, 'Probe Staff', NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (33, '', NULL, false, '', '', '', '2026-08-24 10:50:52.619683+00', 'probecrowd0', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (34, '', NULL, false, '', '', '', '2026-08-24 10:50:52.624817+00', 'probecrowd1', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (35, '', NULL, false, '', '', '', '2026-08-24 10:50:52.628825+00', 'probecrowd2', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (36, '', NULL, false, '', '', '', '2026-08-24 10:50:52.63332+00', 'probecrowd3', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (67, 'pbkdf2_sha256$1000000$gC5OHX7CJ3MYOm0cuAXtuQ$huWdajOZn3G2GeUITCP55CMR/PAl+x3RUbmAg3ql5Eg=', '2026-08-26 16:57:22.975846+00', true, '', '', '', '2026-08-26 16:51:54.56825+00', 'wikitadmin', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, 'Wikit Admin', NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (37, '', NULL, false, '', '', '', '2026-08-24 10:50:52.637053+00', 'probecrowd4', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (38, '', NULL, false, '', '', '', '2026-08-24 10:50:52.64114+00', 'probecrowd5', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (39, '', NULL, false, '', '', '', '2026-08-24 10:50:52.644782+00', 'probecrowd6', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (40, '', NULL, false, '', '', '', '2026-08-24 10:50:52.648893+00', 'probecrowd7', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, NULL, NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (189, 'pbkdf2_sha256$1000000$HmjrOdsw0zJGkixCtLijqe$T0mZ6boolXY+E0oU0XWLwl0uUwYqzEY171rjmmrnDhg=', '2026-08-31 11:10:07.711335+00', false, '', '', '', '2026-08-31 11:09:57.662033+00', 'demo', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, '演示账号', NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (190, 'pbkdf2_sha256$1000000$JMENisbO7lsFXsMDdPLPhT$Qd8yTZEHijgfGE4w1HuUoVcyUTxRIVDfuwfRQhcRSXs=', '2026-08-31 11:10:59.185714+00', true, '', '', '', '2026-08-31 11:10:47.747027+00', 'demoadmin', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, '演示管理员', NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (191, 'pbkdf2_sha256$1000000$4RwXBOBgSKGZytCSN7NIEV$ZTn+JVD/MzSF1XuCPg+tOLVTOEsCSUyaI4teF1r5swg=', '2026-08-31 12:07:17.690408+00', false, '', '', 'newcomer@example.org', '2026-08-31 12:07:05.279873+00', 'newcomer', NULL, 'normal', '', '', NULL, true, NULL, true, NULL, true, 'Newcomer', NULL, '', '', NULL, NULL); +INSERT INTO public.web_user VALUES (31, '', '2026-08-31 12:02:19.152912+00', false, '', '', '', '2026-08-24 10:49:07.531608+00', '576c0df3-8a28-4468-9770-ede851d88c67', 'probe-wd-original', 'wikidot', '', '', NULL, true, NULL, false, NULL, true, 'Probe WD', NULL, '', '', NULL, NULL); + +INSERT INTO public.dynamic_preferences_users_userpreferencemodel VALUES (28, 'qol', 'advanced_source_editor_enabled', 'False', 67); +INSERT INTO public.dynamic_preferences_users_userpreferencemodel VALUES (29, 'qol', 'advanced_source_editor_enabled', 'False', 188); +INSERT INTO public.dynamic_preferences_users_userpreferencemodel VALUES (27, 'qol', 'advanced_source_editor_enabled', 'True', 30); +INSERT INTO public.dynamic_preferences_users_userpreferencemodel VALUES (30, 'qol', 'advanced_source_editor_enabled', 'False', 1); + +INSERT INTO public.web_actionlogentry VALUES (1, 'wikitadmin', 'vote', '{"is_new": true, "article": "scp-173 (scp-173)", "new_vote": 1, "old_vote": null, "is_change": false, "is_remove": false}', '2026-08-30 08:43:17.773435+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (2, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": -1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:43:19.781634+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (3, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": -1, "old_vote": -1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:44:32.149287+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (4, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": 1, "old_vote": -1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:44:33.641333+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (5, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": null, "old_vote": 1.0, "is_change": false, "is_remove": true}', '2026-08-30 08:44:35.249927+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (6, 'wikitadmin', 'vote', '{"is_new": true, "article": "scp-173 (scp-173)", "new_vote": 1, "old_vote": null, "is_change": false, "is_remove": false}', '2026-08-30 08:44:43.063729+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (7, 'probe-staff', 'vote', '{"is_new": true, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": null, "is_change": false, "is_remove": false}', '2026-08-30 08:48:03.320948+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (8, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:03.532547+00', '172.18.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (9, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:13.316658+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (10, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:13.475767+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (11, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:13.676248+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (12, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:13.839397+00', '172.18.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (13, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:14.012926+00', '172.18.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (14, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:48:14.205534+00', '172.18.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (15, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:49:01.979231+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (16, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:49:02.170022+00', '172.18.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (17, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:49:19.951337+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (18, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:54:30.145355+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (19, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": -1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 08:54:31.71078+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (20, 'probe-staff', 'vote', '{"is_new": true, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": null, "is_change": false, "is_remove": false}', '2026-08-30 08:57:46.832836+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (21, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": -1, "old_vote": -1.0, "is_change": true, "is_remove": false}', '2026-08-30 09:02:36.418238+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (22, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": 1, "old_vote": -1.0, "is_change": true, "is_remove": false}', '2026-08-30 09:02:37.714237+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (23, 'probe-staff', 'vote', '{"is_new": true, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": null, "is_change": false, "is_remove": false}', '2026-08-30 09:04:03.718509+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (24, 'probe-staff', 'vote', '{"is_new": false, "article": "Probe Full (probe:full)", "new_vote": -1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 09:05:01.313351+00', '127.0.0.1', 188); +INSERT INTO public.web_actionlogentry VALUES (25, 'wikitadmin', 'vote', '{"is_new": false, "article": "scp-173 (scp-173)", "new_vote": 1, "old_vote": 1.0, "is_change": true, "is_remove": false}', '2026-08-30 09:09:18.004374+00', '127.0.0.1', 67); +INSERT INTO public.web_actionlogentry VALUES (26, 'probe-staff', 'vote', '{"is_new": true, "article": "Probe Full (probe:full)", "new_vote": 1, "old_vote": null, "is_change": false, "is_remove": false}', '2026-08-30 09:11:45.055907+00', '127.0.0.1', 188); + +INSERT INTO public.web_article VALUES (122, 'forum', 'category', 'category', false, '2026-08-26 16:12:44.792185+00', '2026-08-26 16:12:44.79952+00', NULL, '20db24df-6901-432a-8502-43629c9c5dd6', DEFAULT); +INSERT INTO public.web_article VALUES (137, 'probestars', 'quarter', 'Probe Quarter', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'b617d328-a33d-488c-9fb3-69159fb24987', DEFAULT); +INSERT INTO public.web_article VALUES (126, 'probe', 'bydisplay', 'Probe By Display Name', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '909df4ed-d878-4d88-8464-171165e882b2', DEFAULT); +INSERT INTO public.web_article VALUES (132, 'probe', 'listempty', 'Probe List Empty', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '115e44f6-c545-48d2-9338-e0d5441722ab', DEFAULT); +INSERT INTO public.web_article VALUES (3, 'component', 'box', 'box', false, '2026-08-20 07:26:18.337302+00', '2026-08-20 07:26:18.347679+00', NULL, 'c0586cb9-3bd4-4fb9-bfa6-e809d77a1328', DEFAULT); +INSERT INTO public.web_article VALUES (115, 'probe', 'tagged', 'Probe Tagged', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'f351f8be-ecfa-4410-b848-36bc36ff1cd5', DEFAULT); +INSERT INTO public.web_article VALUES (129, 'probe', 'listnowrap', 'Probe List No Wrapper', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '2180212d-66f7-47d7-9c72-f5af9b1955c1', DEFAULT); +INSERT INTO public.web_article VALUES (123, 'forum', 'thread', 'thread', false, '2026-08-26 16:12:44.806389+00', '2026-08-26 16:12:44.812809+00', NULL, 'bdcc7125-f563-4d7d-86a1-ed984a3cdf2e', DEFAULT); +INSERT INTO public.web_article VALUES (5, '_default', 'scp-173', 'scp-173', false, '2026-08-20 07:26:18.36665+00', '2026-08-20 07:26:18.377982+00', NULL, '556e83fe-c554-4b37-b9f0-0c5044c45dc3', DEFAULT); +INSERT INTO public.web_article VALUES (8, 'component', 'probe-var', 'Shared Component', false, '2026-08-23 08:47:08.282178+00', '2026-08-23 08:47:08.332887+00', NULL, '635e0386-4862-4a0d-b7bd-c1cfb31d40d1', DEFAULT); +INSERT INTO public.web_article VALUES (17, 'probe', 'host', 'Probe Host', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '004ee6a2-4e0d-42a2-ab46-ae837a955c7f', DEFAULT); +INSERT INTO public.web_article VALUES (112, 'probe', 'redirect', 'Probe Redirect', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '574d5efc-84c0-46ec-9a21-52eeed32f148', DEFAULT); +INSERT INTO public.web_article VALUES (9, '_default', 'probe-a', 'Page A', false, '2026-08-23 08:47:08.337019+00', '2026-08-23 08:47:08.351223+00', NULL, 'a1d3ec2a-62b8-4f9e-8f3e-d6f1945e17b8', DEFAULT); +INSERT INTO public.web_article VALUES (10, '_default', 'probe-b', 'Page B', false, '2026-08-23 08:47:08.356311+00', '2026-08-23 08:47:08.372248+00', NULL, 'a674ce40-c859-4d9e-a77b-33a7fb48df28', DEFAULT); +INSERT INTO public.web_article VALUES (116, 'probe', 'taggedplain', 'Probe Tagged Plain', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'd904d112-0552-4414-ab0d-a56f9ec71e80', DEFAULT); +INSERT INTO public.web_article VALUES (124, 'forum', 'start', 'start', false, '2026-08-26 16:12:44.819086+00', '2026-08-26 16:12:44.826374+00', NULL, '6316c01c-dca1-47d6-b6ae-67c7b5c4a70a', DEFAULT); +INSERT INTO public.web_article VALUES (138, 'probeoff', 'unratable', 'Probe Unratable', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'b5ae2ba0-3975-4a01-bd18-50422d7ca8d7', DEFAULT); +INSERT INTO public.web_article VALUES (125, 'nav', 'top-impl', 'top-impl', false, '2026-08-26 16:12:44.834388+00', '2026-08-26 16:12:44.840541+00', NULL, 'fda33fce-d131-4887-af95-4c57bdbb708d', DEFAULT); +INSERT INTO public.web_article VALUES (1, 'nav', 'top', 'top', false, '2026-08-20 07:26:18.286289+00', '2026-08-26 16:12:44.852216+00', NULL, 'cfb976b6-df07-4de5-99fa-066f8d95ca42', DEFAULT); +INSERT INTO public.web_article VALUES (2, 'nav', 'side', 'side', false, '2026-08-20 07:26:18.322901+00', '2026-08-26 16:12:44.865597+00', NULL, '8b3d0619-586c-4825-bf86-e8f438ed2bb8', DEFAULT); +INSERT INTO public.web_article VALUES (198, 'probe', 'changes', 'Probe Changes', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '3bfc95cd-ab75-4746-9c20-25c38fe2ae40', DEFAULT); +INSERT INTO public.web_article VALUES (130, 'probe', 'listsections', 'Probe List Sections', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '571c58eb-32a3-4986-ba4a-2f7f4fe115b9', DEFAULT); +INSERT INTO public.web_article VALUES (131, 'probe', 'listtags', 'Probe List Tags', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'abd8cb8d-59f2-45b6-8bcb-18ca01b84cbf', DEFAULT); +INSERT INTO public.web_article VALUES (127, 'probe', 'listed', 'Probe Listed', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '402708c3-5e3b-449d-b96b-3d1392e95539', DEFAULT); +INSERT INTO public.web_article VALUES (13, 'probe', 'bare', 'Probe Bare', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '9b5dadcb-bb25-468b-880d-b85734c851f3', DEFAULT); +INSERT INTO public.web_article VALUES (196, 'probecss', 'styled', 'Probe Styled', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '12998070-4341-4680-a491-36332e6c0125', DEFAULT); +INSERT INTO public.web_article VALUES (113, 'probe', 'described', 'Probe Described', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'cebb5408-4257-403a-854f-86203d16a298', DEFAULT); +INSERT INTO public.web_article VALUES (14, 'probestars', 'rated', 'Probe Rated', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '5f2e2258-312b-44a1-bd74-ecb54fdb4476', DEFAULT); +INSERT INTO public.web_article VALUES (4, '_default', 'main', 'main', false, '2026-08-20 07:26:18.351937+00', '2026-08-26 16:12:44.642178+00', NULL, 'ff3f591b-cb42-491f-9910-057f16724c1a', DEFAULT); +INSERT INTO public.web_article VALUES (114, 'probe', 'imaged', 'Probe Imaged', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '2224ce44-cd57-4cea-91a5-c4171df32a5c', DEFAULT); +INSERT INTO public.web_article VALUES (118, '_default', 'wiki-syntax-guide', 'wiki-syntax-guide', false, '2026-08-26 16:12:44.654543+00', '2026-08-26 16:12:44.741391+00', NULL, '295a3d02-9f5e-45ca-81b8-fdecbcdaa720', DEFAULT); +INSERT INTO public.web_article VALUES (135, 'probestars', 'unrated', 'Probe Unrated', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '00cc8b22-b29f-4fea-a797-d4fc233a7f02', DEFAULT); +INSERT INTO public.web_article VALUES (119, 'search', 'site', 'site', false, '2026-08-26 16:12:44.750837+00', '2026-08-26 16:12:44.758612+00', NULL, '526a25af-efd8-4c24-a51a-64b51201dd81', DEFAULT); +INSERT INTO public.web_article VALUES (197, 'probecss', 'styledhead', 'Probe Styled Head', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '8a0e9fbf-0de5-444e-9200-b344d9eee65a', DEFAULT); +INSERT INTO public.web_article VALUES (120, 'forum', 'recent-posts', 'recent-posts', false, '2026-08-26 16:12:44.766694+00', '2026-08-26 16:12:44.773398+00', NULL, 'a51fe677-0008-4c83-87cf-16013715a98f', DEFAULT); +INSERT INTO public.web_article VALUES (128, 'probe', 'listjoined', 'Probe List Joined', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '8942f67c-bac1-4610-ba13-db758653396e', DEFAULT); +INSERT INTO public.web_article VALUES (121, 'forum', 'new-thread', 'new-thread', false, '2026-08-26 16:12:44.779666+00', '2026-08-26 16:12:44.785956+00', NULL, '96b18252-c2a2-4595-8e97-ac86a9501944', DEFAULT); +INSERT INTO public.web_article VALUES (133, 'probe', 'listurl', 'Probe List Url', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'be874be4-54d6-4b12-971f-eccd6ee05f42', DEFAULT); +INSERT INTO public.web_article VALUES (139, 'probestars', 'third', 'Probe Third', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'b835e572-5c8f-498c-ab19-1cdc8481904d', DEFAULT); +INSERT INTO public.web_article VALUES (117, 'probe', 'unknownmodule', 'Probe Unknown Module', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '78663848-ceed-475b-a373-b756af10f036', DEFAULT); +INSERT INTO public.web_article VALUES (15, 'probe', 'half', 'Probe Half', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, 'a0a9e44d-06d1-444c-a6f1-9e9666d009ba', DEFAULT); +INSERT INTO public.web_article VALUES (134, 'probe', 'listbyvotes', 'Probe List By Votes', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '79db7c53-3cf8-4100-a803-7bd4a2ac4f53', DEFAULT); +INSERT INTO public.web_article VALUES (12, 'probe', 'full', 'Probe Full', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', 11, '0f69121c-4673-408a-9441-7c03b40af8fa', DEFAULT); +INSERT INTO public.web_article VALUES (16, 'probe', 'included', 'Included Page', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '7f1c329b-cfd6-49d8-8284-5c84770022df', DEFAULT); +INSERT INTO public.web_article VALUES (11, 'probe', 'parent', 'Probe Parent', false, '2021-03-04 05:06:07+00', '2022-07-08 09:10:11+00', NULL, '70d39d77-c55a-4930-abd5-e3802d5a1ced', DEFAULT); +INSERT INTO public.web_article VALUES (205, '_default', 'pwikit-demo', 'pwikit 新功能演示', false, '2026-08-31 10:59:02.384683+00', '2026-08-31 10:59:02.384683+00', NULL, 'pwikit-demo', DEFAULT); + +INSERT INTO public.web_article_authors VALUES (1, 1, 1); +INSERT INTO public.web_article_authors VALUES (2, 2, 1); +INSERT INTO public.web_article_authors VALUES (3, 3, 1); +INSERT INTO public.web_article_authors VALUES (4, 4, 1); +INSERT INTO public.web_article_authors VALUES (5, 5, 1); +INSERT INTO public.web_article_authors VALUES (6, 11, 30); +INSERT INTO public.web_article_authors VALUES (7, 12, 30); +INSERT INTO public.web_article_authors VALUES (8, 14, 30); +INSERT INTO public.web_article_authors VALUES (9, 12, 31); +INSERT INTO public.web_article_authors VALUES (10, 15, 30); +INSERT INTO public.web_article_authors VALUES (17, 135, 30); +INSERT INTO public.web_article_authors VALUES (19, 137, 30); +INSERT INTO public.web_article_authors VALUES (20, 138, 30); +INSERT INTO public.web_article_authors VALUES (21, 139, 30); +INSERT INTO public.web_article_authors VALUES (30, 198, 30); + +INSERT INTO public.web_tagscategory VALUES (1, '默认', '', NULL, '_default'); +INSERT INTO public.web_tagscategory VALUES (2, 'lang', '', 1, 'lang'); +INSERT INTO public.web_tagscategory VALUES (3, 'Probe Topic', 'what a page is about', 2, 'topic'); + +INSERT INTO public.web_tag VALUES (1, 'zeta', 1); +INSERT INTO public.web_tag VALUES (2, 'alpha', 1); +INSERT INTO public.web_tag VALUES (3, 'en', 2); +INSERT INTO public.web_tag VALUES (4, 'scp', 3); +INSERT INTO public.web_tag VALUES (5, '_staff', 1); +INSERT INTO public.web_tag VALUES (6, 'aaa', 2); + +INSERT INTO public.web_article_tags VALUES (1, 12, 1); +INSERT INTO public.web_article_tags VALUES (2, 12, 2); +INSERT INTO public.web_article_tags VALUES (3, 12, 3); +INSERT INTO public.web_article_tags VALUES (4, 14, 2); +INSERT INTO public.web_article_tags VALUES (5, 14, 4); +INSERT INTO public.web_article_tags VALUES (6, 135, 2); +INSERT INTO public.web_article_tags VALUES (7, 135, 5); +INSERT INTO public.web_article_tags VALUES (8, 137, 2); +INSERT INTO public.web_article_tags VALUES (9, 137, 4); +INSERT INTO public.web_article_tags VALUES (10, 139, 1); +INSERT INTO public.web_article_tags VALUES (11, 139, 5); +INSERT INTO public.web_article_tags VALUES (12, 138, 4); +INSERT INTO public.web_article_tags VALUES (13, 138, 6); + +INSERT INTO public.web_articlelogentry VALUES (5, 'new', '{"title": "scp-173", "version_id": 5}', '2024-11-12 13:18:15+00', 'seed', 0, 5, 1); +INSERT INTO public.web_articlelogentry VALUES (6, 'title', '{"title": "Shared Component", "prev_title": "probe-var"}', '2024-11-12 13:19:15+00', '', 0, 8, NULL); +INSERT INTO public.web_articlelogentry VALUES (12, 'new', '{"title": "parent", "version_id": 11}', '2024-11-12 13:25:15+00', '', 0, 11, 30); +INSERT INTO public.web_articlelogentry VALUES (16, 'new', '{"title": "half", "version_id": 15}', '2024-11-12 13:29:15+00', '', 0, 15, 30); +INSERT INTO public.web_articlelogentry VALUES (17, 'new', '{"title": "included", "version_id": 16}', '2024-11-12 13:30:15+00', '', 0, 16, NULL); +INSERT INTO public.web_articlelogentry VALUES (21, 'new', '{"title": "described", "version_id": 20}', '2024-11-12 13:33:15+00', '', 0, 113, NULL); +INSERT INTO public.web_articlelogentry VALUES (22, 'new', '{"title": "imaged", "version_id": 21}', '2024-11-12 13:34:15+00', '', 0, 114, NULL); +INSERT INTO public.web_articlelogentry VALUES (23, 'new', '{"title": "tagged", "version_id": 22}', '2024-11-12 13:35:15+00', '', 0, 115, NULL); +INSERT INTO public.web_articlelogentry VALUES (25, 'new', '{"title": "unknownmodule", "version_id": 24}', '2024-11-12 13:37:15+00', '', 0, 117, NULL); +INSERT INTO public.web_articlelogentry VALUES (26, 'source', '{"version_id": 25}', '2024-11-12 13:38:15+00', 'Seeding', 1, 4, NULL); +INSERT INTO public.web_articlelogentry VALUES (27, 'new', '{"title": "wiki-syntax-guide", "version_id": 26}', '2024-11-12 13:39:15+00', 'Seeding', 0, 118, NULL); +INSERT INTO public.web_articlelogentry VALUES (29, 'new', '{"title": "recent-posts", "version_id": 28}', '2024-11-12 13:41:15+00', 'Seeding', 0, 120, NULL); +INSERT INTO public.web_articlelogentry VALUES (42, 'new', '{"title": "listtags", "version_id": 41}', '2024-11-12 13:54:15+00', '', 0, 131, NULL); +INSERT INTO public.web_articlelogentry VALUES (43, 'new', '{"title": "listempty", "version_id": 42}', '2024-11-12 13:55:15+00', '', 0, 132, NULL); +INSERT INTO public.web_articlelogentry VALUES (44, 'new', '{"title": "listurl", "version_id": 43}', '2024-11-12 13:56:15+00', '', 0, 133, NULL); +INSERT INTO public.web_articlelogentry VALUES (45, 'new', '{"title": "listbyvotes", "version_id": 44}', '2024-11-12 13:57:15+00', '', 0, 134, NULL); +INSERT INTO public.web_articlelogentry VALUES (46, 'new', '{"title": "unrated", "version_id": 45}', '2024-11-12 13:58:15+00', '', 0, 135, 30); +INSERT INTO public.web_articlelogentry VALUES (48, 'new', '{"title": "quarter", "version_id": 47}', '2024-11-12 13:59:15+00', '', 0, 137, 30); +INSERT INTO public.web_articlelogentry VALUES (49, 'new', '{"title": "unratable", "version_id": 48}', '2024-11-12 14:00:15+00', '', 0, 138, 30); +INSERT INTO public.web_articlelogentry VALUES (50, 'new', '{"title": "third", "version_id": 49}', '2024-11-12 14:01:15+00', '', 0, 139, 30); +INSERT INTO public.web_articlelogentry VALUES (51, 'new', '{"title": "styled", "version_id": 50}', '2024-11-12 14:02:15+00', '', 0, 196, NULL); +INSERT INTO public.web_articlelogentry VALUES (52, 'new', '{"title": "styledhead", "version_id": 51}', '2024-11-12 14:03:15+00', '', 0, 197, NULL); +INSERT INTO public.web_articlelogentry VALUES (53, 'new', '{"title": "changes", "version_id": 52}', '2024-11-12 14:04:15+00', '', 0, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (241, 'source', '{"version_id": 0}', '2024-11-12 14:05:15+00', 'a source edit', 1, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (242, 'source', '{"version_id": 0}', '2024-11-12 14:06:15+00', ' ', 2, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (243, 'source', '{"version_id": 0}', '2024-11-12 14:07:15+00', '', 3, 198, NULL); +INSERT INTO public.web_articlelogentry VALUES (244, 'source', '{"version_id": 0}', '2024-11-12 14:08:15+00', '', 4, 198, 31); +INSERT INTO public.web_articlelogentry VALUES (245, 'source', '{"version_id": 0}', '2024-11-12 14:09:15+00', '', 5, 198, 32); +INSERT INTO public.web_articlelogentry VALUES (246, 'title', '{"title": "Probe Changes", "prev_title": "Probe \"Old\" "}', '2024-11-12 14:10:15+00', '', 6, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (247, 'name', '{"name": "probe:changes", "prev_name": "probe:was-here"}', '2024-11-12 14:11:15+00', '', 7, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (248, 'tags', '{"added_tags": [{"id": 1, "name": "alpha"}], "removed_tags": []}', '2024-11-12 14:12:15+00', '', 8, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (249, 'tags', '{"added_tags": [], "removed_tags": [{"id": 2, "name": "lang:en"}]}', '2024-11-12 14:13:15+00', '', 9, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (2, 'new', '{"title": "side", "version_id": 2}', '2024-11-12 13:15:15+00', 'seed', 0, 2, 1); +INSERT INTO public.web_articlelogentry VALUES (3, 'new', '{"title": "box", "version_id": 3}', '2024-11-12 13:16:15+00', 'seed', 0, 3, 1); +INSERT INTO public.web_articlelogentry VALUES (4, 'new', '{"title": "main", "version_id": 4}', '2024-11-12 13:17:15+00', 'seed', 0, 4, 1); +INSERT INTO public.web_articlelogentry VALUES (7, 'new', '{"title": "Shared Component", "version_id": 8}', '2024-11-12 13:20:15+00', '', 1, 8, NULL); +INSERT INTO public.web_articlelogentry VALUES (8, 'title', '{"title": "Page A", "prev_title": "probe-a"}', '2024-11-12 13:21:15+00', '', 0, 9, NULL); +INSERT INTO public.web_articlelogentry VALUES (9, 'new', '{"title": "Page A", "version_id": 9}', '2024-11-12 13:22:15+00', '', 1, 9, NULL); +INSERT INTO public.web_articlelogentry VALUES (10, 'title', '{"title": "Page B", "prev_title": "probe-b"}', '2024-11-12 13:23:15+00', '', 0, 10, NULL); +INSERT INTO public.web_articlelogentry VALUES (11, 'new', '{"title": "Page B", "version_id": 10}', '2024-11-12 13:24:15+00', '', 1, 10, NULL); +INSERT INTO public.web_articlelogentry VALUES (13, 'new', '{"title": "full", "version_id": 12}', '2024-11-12 13:26:15+00', '', 0, 12, 30); +INSERT INTO public.web_articlelogentry VALUES (14, 'new', '{"title": "bare", "version_id": 13}', '2024-11-12 13:27:15+00', '', 0, 13, NULL); +INSERT INTO public.web_articlelogentry VALUES (15, 'new', '{"title": "rated", "version_id": 14}', '2024-11-12 13:28:15+00', '', 0, 14, 30); +INSERT INTO public.web_articlelogentry VALUES (18, 'new', '{"title": "host", "version_id": 17}', '2024-11-12 13:31:15+00', '', 0, 17, NULL); +INSERT INTO public.web_articlelogentry VALUES (20, 'new', '{"title": "redirect", "version_id": 19}', '2024-11-12 13:32:15+00', '', 0, 112, NULL); +INSERT INTO public.web_articlelogentry VALUES (24, 'new', '{"title": "taggedplain", "version_id": 23}', '2024-11-12 13:36:15+00', '', 0, 116, NULL); +INSERT INTO public.web_articlelogentry VALUES (28, 'new', '{"title": "site", "version_id": 27}', '2024-11-12 13:40:15+00', 'Seeding', 0, 119, NULL); +INSERT INTO public.web_articlelogentry VALUES (1, 'new', '{"title": "top", "version_id": 1}', '2024-11-12 13:14:15+00', 'seed', 0, 1, 1); +INSERT INTO public.web_articlelogentry VALUES (30, 'new', '{"title": "new-thread", "version_id": 29}', '2024-11-12 13:42:15+00', 'Seeding', 0, 121, NULL); +INSERT INTO public.web_articlelogentry VALUES (31, 'new', '{"title": "category", "version_id": 30}', '2024-11-12 13:43:15+00', 'Seeding', 0, 122, NULL); +INSERT INTO public.web_articlelogentry VALUES (32, 'new', '{"title": "thread", "version_id": 31}', '2024-11-12 13:44:15+00', 'Seeding', 0, 123, NULL); +INSERT INTO public.web_articlelogentry VALUES (33, 'new', '{"title": "start", "version_id": 32}', '2024-11-12 13:45:15+00', 'Seeding', 0, 124, NULL); +INSERT INTO public.web_articlelogentry VALUES (262, 'votes_deleted', '{"rating": 0, "popularity": 0, "rating_mode": "disabled", "votes_count": 0}', '2024-11-12 14:26:15+00', '', 22, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (34, 'new', '{"title": "top-impl", "version_id": 33}', '2024-11-12 13:46:15+00', 'Seeding', 0, 125, NULL); +INSERT INTO public.web_articlelogentry VALUES (35, 'source', '{"version_id": 34}', '2024-11-12 13:47:15+00', 'Seeding', 1, 1, NULL); +INSERT INTO public.web_articlelogentry VALUES (36, 'source', '{"version_id": 35}', '2024-11-12 13:48:15+00', 'Seeding', 1, 2, NULL); +INSERT INTO public.web_articlelogentry VALUES (37, 'new', '{"title": "bydisplay", "version_id": 36}', '2024-11-12 13:49:15+00', '', 0, 126, NULL); +INSERT INTO public.web_articlelogentry VALUES (38, 'new', '{"title": "listed", "version_id": 37}', '2024-11-12 13:50:15+00', '', 0, 127, NULL); +INSERT INTO public.web_articlelogentry VALUES (39, 'new', '{"title": "listjoined", "version_id": 38}', '2024-11-12 13:51:15+00', '', 0, 128, NULL); +INSERT INTO public.web_articlelogentry VALUES (40, 'new', '{"title": "listnowrap", "version_id": 39}', '2024-11-12 13:52:15+00', '', 0, 129, NULL); +INSERT INTO public.web_articlelogentry VALUES (41, 'new', '{"title": "listsections", "version_id": 40}', '2024-11-12 13:53:15+00', '', 0, 130, NULL); +INSERT INTO public.web_articlelogentry VALUES (250, 'tags', '{"added_tags": [{"id": 1, "name": "alpha"}, {"id": 3, "name": "Zeta"}], "removed_tags": [{"id": 2, "name": "lang:en"}]}', '2024-11-12 14:14:15+00', '', 10, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (251, 'tags', '{}', '2024-11-12 14:15:15+00', '', 11, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (252, 'parent', '{"parent": "probe:parent", "prev_parent": null}', '2024-11-12 14:16:15+00', '', 12, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (253, 'parent', '{"parent": null, "prev_parent": "probe:parent"}', '2024-11-12 14:17:15+00', '', 13, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (254, 'parent', '{"parent": "probe:full", "prev_parent": "probe:parent"}', '2024-11-12 14:18:15+00', '', 14, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (255, 'parent', '{"parent": null, "prev_parent": null}', '2024-11-12 14:19:15+00', '', 15, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (256, 'file_added', '{"id": 1, "name": "cover.png"}', '2024-11-12 14:20:15+00', '', 16, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (257, 'file_deleted', '{"id": 1, "name": "cover.png"}', '2024-11-12 14:21:15+00', '', 17, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (258, 'file_renamed', '{"name": "banner.png", "prev_name": "cover.png"}', '2024-11-12 14:22:15+00', '', 18, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (259, 'votes_deleted', '{"rating": 3, "popularity": 60, "rating_mode": "updown", "votes_count": 5}', '2024-11-12 14:23:15+00', '', 19, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (260, 'votes_deleted', '{"rating": -3.7, "popularity": 11, "rating_mode": "updown", "votes_count": 9}', '2024-11-12 14:24:15+00', '', 20, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (261, 'votes_deleted', '{"rating": 4.25, "popularity": 75, "rating_mode": "stars", "votes_count": 4}', '2024-11-12 14:25:15+00', '', 21, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (263, 'authorship', '{"added_authors": [30], "removed_authors": []}', '2024-11-12 14:27:15+00', '', 23, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (264, 'authorship', '{"added_authors": [30, 32], "removed_authors": []}', '2024-11-12 14:28:15+00', '', 24, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (265, 'authorship', '{"added_authors": [], "removed_authors": [31]}', '2024-11-12 14:29:15+00', '', 25, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (266, 'authorship', '{"added_authors": [32], "removed_authors": [31]}', '2024-11-12 14:30:15+00', '', 26, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (267, 'authorship', '{}', '2024-11-12 14:31:15+00', '', 27, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (268, 'wikidot', '{}', '2024-11-12 14:32:15+00', 'imported from wikidot', 28, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (269, 'revert', '{"subtypes": ["source", "title"], "rev_number": 2}', '2024-11-12 14:33:15+00', '', 29, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (270, 'revert', '{"subtypes": [], "rev_number": 0}', '2024-11-12 14:34:15+00', '', 30, 198, 30); +INSERT INTO public.web_articlelogentry VALUES (271, 'revert', '{"rev_number": 1}', '2024-11-12 14:35:15+00', '', 31, 198, 30); + +INSERT INTO public.web_articlesearchindex VALUES (1, '[[module ForumCategory]] + +[!-- 如果您希望论坛正常工作,请不要更改此页面 --] +', '[[module ForumCategory]] + +[!-- 如果您希望论坛正常工作,请不要更改此页面 --] +', '''forumcategori'':2,6 ''modul'':1,5 ''如果您希望论坛正常工作'':3,7 ''请不要更改此页面'':4,8', 122); +INSERT INTO public.web_articlesearchindex VALUES (2, 'quarter source +[[[wanted:beta]]] [[[probe:no-such-one]]]', 'quarter source +[[[wanted:beta]]] [[[probe:no-such-one]]]', '''beta'':4,13 ''no-such-on'':6,15 ''one'':9,18 ''probe'':5,14 ''quarter'':1,10 ''sourc'':2,11 ''want'':3,12', 137); +INSERT INTO public.web_articlesearchindex VALUES (3, '[[*user Probe WD]]', '[[*user Probe WD]]', '''probe'':2,5 ''user'':1,4 ''wd'':3,6', 126); +INSERT INTO public.web_articlesearchindex VALUES (4, '[[module ListPages category="probe" name="no-such-name-at-all"]] +%%name%% +[[/module]]', '[[module ListPages category="probe" name="no-such-name-at-all"]] +%%name%% +[[/module]]', '''/module'':13,26 ''categori'':3,16 ''listpag'':2,15 ''modul'':1,14 ''name'':5,9,12,18,22,25 ''no-such-name-at-al'':6,19 ''probe'':4,17', 132); +INSERT INTO public.web_articlesearchindex VALUES (5, '[[div class="box"]] +这是一个被 include 的组件。参数 a = %%a%% +[[/div]]', '[[div class="box"]] +这是一个被 include 的组件。参数 a = %%a%% +[[/div]]', '''/div'':10,20 ''box'':3,13 ''class'':2,12 ''div'':1,11 ''includ'':5,15 ''参数'':7,17 ''的组件'':6,16 ''这是一个被'':4,14', 3); +INSERT INTO public.web_articlesearchindex VALUES (6, '[[module PagesByTag tag="lang:en"]]', '[[module PagesByTag tag="lang:en"]]', '''en'':5,10 ''lang'':4,9 ''modul'':1,6 ''pagesbytag'':2,7 ''tag'':3,8', 115); +INSERT INTO public.web_articlesearchindex VALUES (7, '[[module ListPages category="probe" order="name" wrapper="no" limit="2"]] +%%name%% +[[/module]]', '[[module ListPages category="probe" order="name" wrapper="no" limit="2"]] +%%name%% +[[/module]]', '''/module'':12,24 ''2'':10,22 ''categori'':3,15 ''limit'':9,21 ''listpag'':2,14 ''modul'':1,13 ''name'':6,11,18,23 ''order'':5,17 ''probe'':4,16 ''wrapper'':7,19', 129); +INSERT INTO public.web_articlesearchindex VALUES (8, '[[module ForumThread]] + +[!-- 如果您希望论坛正常工作,请不要更改此页面 --] +', '[[module ForumThread]] + +[!-- 如果您希望论坛正常工作,请不要更改此页面 --] +', '''forumthread'':2,6 ''modul'':1,5 ''如果您希望论坛正常工作'':3,7 ''请不要更改此页面'':4,8', 123); +INSERT INTO public.web_articlesearchindex VALUES (9, '[[include component:box a=173]] + +**项目编号:** SCP-173 + +**项目等级:** Euclid + ++ 描述 + +一个测试用条目,包含 [[[main | 内链]]]、[[[another-missing | 红链]]] 和一个代码块: + +[[code type="python"]] +print("hello") +[[/code]] + +[[module Rate]]', '[[include component:box a=173]] + +**项目编号:** SCP-173 + +**项目等级:** Euclid + ++ 描述 + +一个测试用条目,包含 [[[main | 内链]]]、[[[another-missing | 红链]]] 和一个代码块: + +[[code type="python"]] +print("hello") +[[/code]] + +[[module Rate]]', '''-173'':8,36 ''/code'':26,54 ''173'':5,33 ''anoth'':17,45 ''another-miss'':16,44 ''box'':3,31 ''code'':21,49 ''compon'':2,30 ''euclid'':10,38 ''hello'':25,53 ''includ'':1,29 ''main'':14,42 ''miss'':18,46 ''modul'':27,55 ''print'':24,52 ''python'':23,51 ''rate'':28,56 ''scp'':7,35 ''type'':22,50 ''一个测试用条目'':12,40 ''内链'':15,43 ''包含'':13,41 ''和一个代码块'':20,48 ''描述'':11,39 ''红链'':19,47 ''项目等级'':9,37 ''项目编号'':6,34', 5); +INSERT INTO public.web_articlesearchindex VALUES (10, 'bare source', 'bare source', '''bare'':1,3 ''sourc'':2,4', 13); +INSERT INTO public.web_articlesearchindex VALUES (11, '本组件被谁包含: %%this|title%% / %%this|fullname%% / 评分 %%this|rating%%', '本组件被谁包含: %%this|title%% / %%this|fullname%% / 评分 %%this|rating%%', '''fullnam'':5,13 ''rate'':8,16 ''titl'':3,11 ''本组件被谁包含'':1,9 ''评分'':6,14', 8); +INSERT INTO public.web_articlesearchindex VALUES (12, '[[include probe:included]]', '[[include probe:included]]', '''includ'':1,3,4,6 ''probe'':2,5', 17); +INSERT INTO public.web_articlesearchindex VALUES (13, 'before +[[module Redirect destination="/probe:full"]] +after', 'before +[[module Redirect destination="/probe:full"]] +after', '''/probe'':5,11 ''destin'':4,10 ''full'':6,12 ''modul'':2,8 ''redirect'':3,9', 112); +INSERT INTO public.web_articlesearchindex VALUES (14, '[[include component:probe-var]]', '[[include component:probe-var]]', '''compon'':2,7 ''includ'':1,6 ''probe'':4,9 ''probe-var'':3,8 ''var'':5,10', 9); +INSERT INTO public.web_articlesearchindex VALUES (15, '[[include component:probe-var]]', '[[include component:probe-var]]', '''compon'':2,7 ''includ'':1,6 ''probe'':4,9 ''probe-var'':3,8 ''var'':5,10', 10); +INSERT INTO public.web_articlesearchindex VALUES (16, '[[module PagesByTag tag="zeta"]]', '[[module PagesByTag tag="zeta"]]', '''modul'':1,5 ''pagesbytag'':2,6 ''tag'':3,7 ''zeta'':4,8', 116); +INSERT INTO public.web_articlesearchindex VALUES (17, '[[div class="new-post"]] +[[[forum:recent-posts|论坛新帖]]] +[[/div]] + +[[module ForumStart]] + +[!-- 如果您希望论坛正常工作,请不要更改此页面 --]', '[[div class="new-post"]] +[[[forum:recent-posts|论坛新帖]]] +[[/div]] + +[[module ForumStart]] + +[!-- 如果您希望论坛正常工作,请不要更改此页面 --]', '''/div'':11,26 ''class'':2,17 ''div'':1,16 ''forum'':6,21 ''forumstart'':13,28 ''modul'':12,27 ''new'':4,19 ''new-post'':3,18 ''post'':5,9,20,24 ''recent'':8,23 ''recent-post'':7,22 ''如果您希望论坛正常工作'':14,29 ''论坛新帖'':10,25 ''请不要更改此页面'':15,30', 124); +INSERT INTO public.web_articlesearchindex VALUES (18, 'unratable source +[[[wanted:gamma]]] [[[probe:full|a page that exists]]]', 'unratable source +[[[wanted:gamma]]] [[[probe:full|a page that exists]]]', '''exist'':10,20 ''full'':6,16 ''gamma'':4,14 ''page'':8,18 ''probe'':5,15 ''sourc'':2,12 ''unrat'':1,11 ''want'':3,13', 138); +INSERT INTO public.web_articlesearchindex VALUES (19, '* [# 这里] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 是] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 一个] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 示例] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 顶部栏] + * [https://github.com/WikitTeam/ProjectWikit GitHub页面] + * [[[/forum/start|论坛]]] + * [[[/forum:recent-posts|最新帖子]]] + * [[[/wiki-syntax-guide|维基语法指南]]] +', '* [# 这里] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 是] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 一个] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 示例] + * [[[main|鱼!]]] + * [[[main|鱼!]]] + * [[[main|鱼!]]] +* [# 顶部栏] + * [https://github.com/WikitTeam/ProjectWikit GitHub页面] + * [[[/forum/start|论坛]]] + * [[[/forum:recent-posts|最新帖子]]] + * [[[/wiki-syntax-guide|维基语法指南]]] +', '''/forum'':48,102 ''/forum/start'':46,100 ''/wiki-syntax-guide'':53,107 ''/wikitteam/projectwikit'':44,98 ''github.com'':43,97 ''github.com/wikitteam/projectwikit'':42,96 ''github页面'':45,99 ''main'':2,4,6,8,10,13,15,17,19,22,24,26,28,30,32,35,37,39,56,58,60,62,64,67,69,71,73,76,78,80,82,84,86,89,91,93 ''post'':51,105 ''recent'':50,104 ''recent-post'':49,103 ''一个'':21,75 ''是'':12,66 ''最新帖子'':52,106 ''示例'':34,88 ''维基语法指南'':54,108 ''论坛'':47,101 ''这里'':1,55 ''顶部栏'':41,95 ''鱼'':3,5,7,9,11,14,16,18,20,23,25,27,29,31,33,36,38,40,57,59,61,63,65,68,70,72,74,77,79,81,83,85,87,90,92,94', 125); +INSERT INTO public.web_articlesearchindex VALUES (20, '[[div class="top-bar"]] +[[include nav:top-impl]] +[[/div]] + +[[div class="mobile-top-bar"]] +[[div class="open-menu"]] +[#side-bar ≡] +[[/div]] +[[include nav:top-impl]] +[[/div]]', '[[div class="top-bar"]] +[[include nav:top-impl]] +[[/div]] + +[[div class="mobile-top-bar"]] +[[div class="open-menu"]] +[#side-bar ≡] +[[/div]] +[[include nav:top-impl]] +[[/div]]', '''/div'':11,26,32,43,58,64 ''bar'':5,17,25,37,49,57 ''class'':2,13,19,34,45,51 ''div'':1,12,18,33,44,50 ''impl'':10,31,42,63 ''includ'':6,27,38,59 ''menu'':22,54 ''mobil'':15,47 ''mobile-top-bar'':14,46 ''nav'':7,28,39,60 ''open'':21,53 ''open-menu'':20,52 ''side'':24,56 ''side-bar'':23,55 ''top'':4,9,16,30,36,41,48,62 ''top-bar'':3,35 ''top-impl'':8,29,40,61', 1); +INSERT INTO public.web_articlesearchindex VALUES (21, '++ 存在的页面: + +[[module listpages category="*" separate="False" prependLine="|| **标题** || **名称** ||"]] +|| %%title_linked%% || %%fullname%% || +[[/module]]', '++ 存在的页面: + +[[module listpages category="*" separate="False" prependLine="|| **标题** || **名称** ||"]] +|| %%title_linked%% || %%fullname%% || +[[/module]]', '''/module'':13,26 ''categori'':4,17 ''fals'':6,19 ''fullnam'':12,25 ''link'':11,24 ''listpag'':3,16 ''modul'':2,15 ''prependlin'':7,20 ''separ'':5,18 ''titl'':10,23 ''名称'':9,22 ''存在的页面'':1,14 ''标题'':8,21', 2); +INSERT INTO public.web_articlesearchindex VALUES (22, '[[module SiteChanges]]', '[[module SiteChanges]]', '''modul'':1,3 ''sitechang'':2,4', 198); +INSERT INTO public.web_articlesearchindex VALUES (23, '[[module ListPages category="probe" order="name" perPage="2"]] +[[head]] +top +[[/head]] +[[body]] +%%name%% +[[/body]] +[[foot]] +bottom +[[/foot]] +[[/module]]', '[[module ListPages category="probe" order="name" perPage="2"]] +[[head]] +top +[[/head]] +[[body]] +%%name%% +[[/body]] +[[foot]] +bottom +[[/foot]] +[[/module]]', '''/body'':14,32 ''/foot'':17,35 ''/head'':11,29 ''/module'':18,36 ''2'':8,26 ''bodi'':12,30 ''bottom'':16,34 ''categori'':3,21 ''foot'':15,33 ''head'':9,27 ''listpag'':2,20 ''modul'':1,19 ''name'':6,13,24,31 ''order'':5,23 ''perpag'':7,25 ''probe'':4,22 ''top'':10,28', 130); +INSERT INTO public.web_articlesearchindex VALUES (24, '[[module ListPages category="*" tags="+lang:en -zeta" order="fullname"]] +%%fullname%% +[[/module]]', '[[module ListPages category="*" tags="+lang:en -zeta" order="fullname"]] +%%fullname%% +[[/module]]', '''/module'':11,22 ''categori'':3,14 ''en'':6,17 ''fullnam'':9,10,20,21 ''lang'':5,16 ''listpag'':2,13 ''modul'':1,12 ''order'':8,19 ''tag'':4,15 ''zeta'':7,18', 131); +INSERT INTO public.web_articlesearchindex VALUES (25, '[[module ListPages category="probe" order="name" perPage="3" separate="yes"]] +%%index%%/%%total%% [[[%%fullname%%|%%title%%]]] %%rating%% +[[/module]]', '[[module ListPages category="probe" order="name" perPage="3" separate="yes"]] +%%index%%/%%total%% [[[%%fullname%%|%%title%%]]] %%rating%% +[[/module]]', '''/module'':16,32 ''3'':8,24 ''categori'':3,19 ''fullnam'':13,29 ''index'':11,27 ''listpag'':2,18 ''modul'':1,17 ''name'':6,22 ''order'':5,21 ''perpag'':7,23 ''probe'':4,20 ''rate'':15,31 ''separ'':9,25 ''titl'':14,30 ''total'':12,28 ''yes'':10,26', 127); +INSERT INTO public.web_articlesearchindex VALUES (26, '[[module CSS]] +#page-content { color : red ; } +@media (max-width: 767px) { #main { padding : 0 ; } } +[[/module]] +styled body', '[[module CSS]] +#page-content { color : red ; } +@media (max-width: 767px) { #main { padding : 0 ; } } +[[/module]] +styled body', '''/module'':16,34 ''0'':15,33 ''767px'':12,30 ''bodi'':18,36 ''color'':6,24 ''content'':5,23 ''css'':2,20 ''main'':13,31 ''max'':10,28 ''max-width'':9,27 ''media'':8,26 ''modul'':1,19 ''pad'':14,32 ''page'':4,22 ''page-cont'':3,21 ''red'':7,25 ''style'':17,35 ''width'':11,29', 196); +INSERT INTO public.web_articlesearchindex VALUES (27, 'visible text +[[module PageDescription]]custom description[[/module]]', 'visible text +[[module PageDescription]]custom description[[/module]]', '''/module'':7,14 ''custom'':5,12 ''descript'':6,13 ''modul'':3,10 ''pagedescript'':4,11 ''text'':2,9 ''visibl'':1,8', 113); +INSERT INTO public.web_articlesearchindex VALUES (28, 'rated source', 'rated source', '''rate'':1,3 ''sourc'':2,4', 14); +INSERT INTO public.web_articlesearchindex VALUES (29, '+ 恭喜!一切正常!', '+ 恭喜!一切正常!', '''一切正常'':2,4 ''恭喜'':1,3', 4); +INSERT INTO public.web_articlesearchindex VALUES (30, '[[module PageImage src="probe:full/cover.png"]]body text', '[[module PageImage src="probe:full/cover.png"]]body text', '''bodi'':6,13 ''full/cover.png'':5,12 ''modul'':1,8 ''pageimag'':2,9 ''probe'':4,11 ''src'':3,10 ''text'':7,14', 114); +INSERT INTO public.web_articlesearchindex VALUES (31, '以下指南是关于PojectWikit网站整体运行机制以及维基标记语言(wiki markup)相关内容的技术文档。 + +本指南面向具备最低限度 HTML 和 CSS 基础知识的读者;其目标是全面、详尽地描述所有可用功能,并解释为什么某些内容会以目前这种方式运行。 + +[[toc]] + +[[module CSS]] + +#page-content h1, #page-content h2, #page-content h3 { + padding-bottom: 8px; + border-bottom: 1px solid #aaa; + margin-top: 32px; + clear: both; +} + +#page-content h1 + h2, #page-content h2 + h3 { + margin-top: 16px; +} + +code, .code, .code pre { + background: #f7f7f7; + color: #050; + font-weight: 500; + font-family: ''Cascadia Mono'', ''Courier New'', Courier, FreeMono, monospace; +} + +code { + padding: 4px; + border-radius: 4px; + white-space: nowrap; +} + +.code { + padding: 8px; +} + +.code p, .code pre { + margin: 0; +} + +#page-content dl { + display: grid; + grid-template-columns: max-content max-content; + border: 1px solid #eee; + border-radius: 8px; + float: right; + overflow: hidden; + margin-left: 32px; + margin-bottom: 32px; + background: white; +} + +#page-content dl dd, #page-content dl dt { + padding: 8px; + border-bottom: 1px solid #eee; + margin: 0; +} + +#page-content dl dd { + text-align: right; +} + +.actual-page-content a[href^="#"] { + border-bottom: 1px dotted #050; + color: #050; + text-decoration: none; +} + +.actual-page-content a[href^="#"]:hover { + border-bottom-style: solid; +} + @media (max-width: 700px) { +code { + white-space: wrap; + word-break: break-all; +} + } +[[/module]] + +[[div class="actual-page-content"]] + ++ 引言 + +标记语言分为四种类型: + +* **自动替换** 在文章开始处理之前执行。因此,自动替换允许在文章中添加新的代码,这些代码随后会作为语法被处理。详见 {{[[include]]}} 章节。 + +* **段落划分** 按既定规则自动进行。 + +* **自由语法** 没有严格的格式;每个标记元素可能以完全不可预测的方式被解析。这些元素不总是彼此兼容,也不一定与块级元素兼容。 + +* **块级元素** 具有严格规则;其格式在外观上类似于 HTML 标记或 BBCode。块级元素具有名称、属性、修饰符。那些原则上可以包含其他元素的块级元素,对其所包含元素的类型不作限制(包括其他块级元素)。 + ++ [[# autoreplace]] 自动替换 + +++ {{[[include]]}}:从其他文章插入代码 + +语法: + +[[div class="code"]] +@@[[include 文章名称 参数1 = 值1 | 参数2 = 值2]]@@ +[[/div]] + +为了使该元素正常工作,在起始的 {{[[}} 前面从行首开始不得有任何文本(包括空格)。同样,在结束的 {{]]}} 之后也不能有任何文本。 + +元素 {{[[include]]}},以及其中的各个参数,都可以占用多行。 + +使用该元素时,系统会访问指定的站点文章,获取其源代码,并将该源代码插入到 {{[[include]]}} 所在的位置。 + +在插入之前,会对指定文章中的所有变量进行自动替换。例如,形如 {{@@{$参数1}@@}} 的变量将被替换为在该元素中指定的对应参数值。 + +由于插入源代码是在“行级”而非“元素级”进行的,因此被嵌入的文章中可以包含完整或部分源代码。同样,{{[[include]]}} 的参数中也可以包含完整或部分源代码。 + +示例: + +* 文章 {{page1}} 中的代码: _ +[[div class="code"]] +@@{$param}@@ +[[/div]] + +* 使用 {{[[include]]}} 的文章中的代码: _ +[[div class="code"]] +@@[[include page1 param=[[div class="code"]] ]]@@ +@@text@@ +@@[[include page1 param=[[/div]] ]]@@ +[[/div]] + +* 结果: _ +[[div class="code"]] +@@[[div class="code"]]@@ +@@text@@ +@@[[/div]]@@ +[[/div]] + +++ {{[[noinclude]]}}:在被插入到其他页面时忽略部分代码 + +语法: + +[[div class="code"]] +@@[[noinclude]]@@ +...任意文本... +@@[[/noinclude]]@@ +[[/div]] + +某些站点组件同时包含可调用代码(组件本身)、使用说明文档以及预览内容。 + +为了防止这些可视化元素被包含到作者文章中,可以使用 {{[[noinclude]]}} 标签。 + +无论是起始还是结束的 {{[[noinclude]]}} 标签,都必须单独占据一整行,否则标签不会生效。这样设计是为了降低误触发或错误触发的概率,例如在记录该功能自身文档时。 + +++ 分类模板 + +分类模板是形如 [[[component:_template|component:_template]]] 的隐藏页面。对于主分类,页面名称为 [[[_default:_template|_template]]]. + +如果为某个分类(例如此处的 {{component}})指定了模板,那么该模板将会为该分类下的所有文章渲染显示,**而不是文章的实际代码**。同时,模板中支持 [#module-listpages ListPages 模块] 中使用的所有变量。例如,可以通过 {{%%content%%}} 获取原始文章代码。 + +++ [[# path-params]] {{%%path%%}}, {{%%path_expr%%}}, {{%%path_url%%}}:页面参数 + +站点引擎支持通过形如 {{/参数/值}} 的语法在页面地址中传递参数。 + +例如,为了在文章 {{page1}} 中访问参数 {{%%param%%}} 和 {{%%param2%%}},可以通过如下地址访问: + +{{@@https://projwikit.unitreaty.org/page1/param/example1/param2/example2@@}} + +由于这些变量替换属于自动替换,目标文章可以通过三种方式访问参数: + +* {{%%path|param%%}} 直接将变量值插入文章代码;如果未指定该变量,则插入文本 {{%%path|param%%}}。 + +* {{%%path_expr|param%%}} 以 JSON 字符串格式插入变量值;若未指定,则插入文本 {{"%%path_expr|param%%"}}。这允许在块级元素属性中传递包含特殊字符的复杂值(例如 {{@@[[input type="text" value=%%path_expr|param%%]]@@}} 可确保即便用户使用特殊字符,值也能正确写入)。 + +* {{%%path_url|param%%}} 插入 URL 编码格式的变量值;若未指定,则插入 {{%25%25path_url%7Cparam%25%25}}。这允许在链接或传递给其他页面的参数中使用这些值(例如 {{@@[[module Redirect to="/other_page/param/%%path_url|param%%"]]@@}})。 + +++ 排版符号的自动替换 + +* {{@<`>@文本@<'>@}} —— 替换为 ‘文本’。 + +* {{@<`>@@<`>@文本@<'>@@<'>@}} —— 替换为 “文本”。 + +* {{@<,>@@<,>@文本@<'>@@<'>@}} —— 替换为 „文本”。 + +[!-- * {{@<.>@@<.>@@<.>@}}, {{@<.>@ @<.>@ @<.>@}} —— 替换为符号 "…". --] [!-- 暂时移除 // jewalky --] + +需要注意的是,由于这些符号替换发生在自动替换阶段,因此可能跨多行发生,甚至包括在 [#literals 字面量]、{{@@[[code]]@@}}、{{@@[[module]]@@}} 等内容中;请务必注意。 + ++ 段落划分 + +系统中所有可以包含其他元素的元素,分为两大类: + +* 行内元素。包括普通文本、所有文本格式元素,以及 {{@@[[span]]@@}} 和其他诸如 {{@@[[image]]@@}}、{{@@[[user]]@@}} 等元素。一般来说,如果该元素默认显示为 {{display: inline}} 或 {{display: inline-block}},则可视为行内元素。 + +* 全宽元素。包括标题、分隔线、列表、{{@@[[toc]]@@}}、{{@@[[div]]@@}}、{{@@[[blockquote]]@@}}、{{@@[[footnoteblock]]@@}}、{{@@[[collapsible]]@@}} 等。大致对应 {{display: block}}。 + +尽管上文提及 CSS 属性,但该属性的实际值不会影响段落生成,因为元素的分类是在其从标记转换为 HTML 的初始阶段完成的。 + +段落会被创建: + +* 在全宽块级元素中,如果未为其指定修饰符 {{_}}(例如 {{@@[[div_]]@@}})。_ +该修饰符并非对所有块级元素都可用,详见各元素说明。 + +* 在简单引用块({{>}})中。 + +段落不会被创建: + +* 在任何行内元素中。 + +* 在块级表格({{@@[[table]]@@}})中。 + +* 在大多数自由语法元素中({{>}} 除外)。_ +可以通过将所需文本包裹在 {{@@[[div]]@@}} 或 {{@@[[p]]@@}} 中来绕过该限制,例如:_ +[[code]]|| [[div]]第一行 + +第二行[[/div]] || 下一个表格单元格 ||[[/code]]在此示例中,{{@@[[div]]@@}} 内的内容会被包裹为段落,而下一个表格单元格则会被直接作为文本添加。_ +该技巧同样适用于块级表格。 + +要在支持段落的元素中创建或分隔新段落,需要满足以下多个条件: + +* 该行必须是元素中的第一行,或者其上方至少有一整行空行,或者其上方存在一个全宽元素。 + +* 该行必须仅包含行内元素。全宽元素周围不会创建段落。在段落内部插入全宽元素会在该处终止当前段落,并在该全宽元素之后创建新段落。 + +如果在不创建段落的元素中存在文本(根据上述任一条件),文本中的空行将被视为普通换行({{
      }},而不是 {{

      }})。 + +++ 控制换行 + +如果你希望空行仅作为空行,而不是创建段落,可以使用两种方法: + +* 在该行放置任何视觉上为空的元素(但在段落判定上不视为空)。例如 {{@@[[span]][[/span]]@@}}、{{@<@>@@<@>@@<@>@@<@>@}}、{{@<@<>@@<>@>@}}。 + +* 在该行末尾添加符号 {{_}}。该符号会被明确解释为换行,并且绝不会转换为段落。 + +你也可以在代码分成多行时阻止换行(以及段落创建)。这在编写复杂代码时非常有用,可以保持可读性,同时不在视觉上拆分文本。为此,请在行末添加 {{\}},则下一行会“粘连”到当前行。例如,下列代码在插入文章后将显示为一行 “abc”: + +[[div class="code"]] +@@a\@@ +@@[[span class="some-class"]]\@@ +@@b\@@ +@@[[/span]]\@@ +@@c@@ +[[/div]] + ++ 自由语法 + +++ 文本格式 + +* {{@@**文本**@@}} —— **粗体** 文本。 + +* {{@@//文本//@@}} —— //斜体// 文本。 + +* {{@@{{文本}}@@}} —— 等宽文本。 + +* {{@@--文本--@@}} —— --删除线-- 文本。 + +* {{@@^^文本^^@@}} —— ^^上标^^ 文本。 + +* {{@@,,文本,,@@}} —— ,,下标,, 文本。 + +* {{@@__文本__@@}} —— __下划线__ 文本。 + +所有上述文本格式化方式都遵循相同的规则: + +* 元素与其内部文本之间不得有空格(例如,{{@@__ 文本 __@@}} 不是正确语法)。 + +* 元素可以跨多行,但不能跨多个段落。 + 正确: +[[code]]//a +b +c//[[/code]] + 错误: +[[code]]//a + +b + +c//[[/code]] + +* 元素内部可以包含任何其他元素,包括块级元素。在使用块级元素的情况下,段落限制将被解除。例如: +[[code]]//[[div]]a + +b + +c[[/div]]//[[/code]] + +同时也支持文本着色: + +* {{@@##颜色|文本##@@}} — ##red|有颜色的## 文本。 + 颜色可以使用任何 CSS 支持的颜色,例如表达式 {{@@##rgba(255, 127, 0, 0.5)|文本##@@}} 是合法的。 + 当使用十六进制值表示颜色(RRGGBB、RRGGBBAA、RGB、RGBA)时,不必额外使用 {{#}} 符号(例如:{{@@##ffe|文本##@@}} 与 {{@@###ffe|文本##@@}} 等效)。 + +++ [[# links]] 链接 + +[[ul]] +[[li]]{{@@[地址 文本]@@}} 或 {{[*地址 文本]}} — 普通链接。 +通常用于外部链接,或在站内文章之间使用参数进行链接,或用于锚点链接(例如:{{@@[#toc-0 指向第一个标题的链接]@@}})。 +地址不能包含空格,而链接文本可以包含空格。链接文本不得跨多行。 +如果链接以星号 {{*}} 开头,则会在新窗口(标签页)中打开。 +[[/li]] + +[[li]]{{@@[[[文章]]]@@}} 或 {{@@[[[文章|]]]@@}} 或 {{@@[[[文章|链接文本]]]@@}} — 使用完整标识符(地址)创建站内文章链接。 +此类链接会显示在文章的反向链接中;如果目标文章不存在,则会以不同颜色显示。 + +语法变体: + +[[ul]] + [[li]]{{@@[[[category:page1]]]@@}} — 创建指向 {{category:page1}} 的链接,链接文本大致对应文章标识符(符号 {{-}} 会被替换为空格,分类前缀会被移除等)。 + [[/li]] + + [[li]]{{@@[[[category:page1|]]]@@}} — 创建指向 {{category:page1}} 的链接。 + 如果该文章存在,则自动使用其标题作为链接文本;否则使用其标识符。 + [[/li]] + + [[li]]{{@@[[[category:page1|文本]]]@@}} — 创建指向 {{category:page1}} 的链接,并使用指定文本作为链接名称。 + 链接文本同样不得跨多行。 + [[/li]] +[[/ul]] +[[/li]] + +[[li]]自动链接:形如 {{@@http://...@@}}、{{@@ftp://...@@}} 的文本会自动转换为对应链接。 +这种自动替换可能会引发问题并破坏其他语法。如需避免,可以使用 [#literals 字面量]。 +[[/li]] +[[/ul]] + +++ 元素位置控制 + +* {{@@= 文本@@}} — 段落居中。必须放在段落开头使用,此后整个段落(包括后续行)都会居中对齐。 + +* {{@@_@@}} — 显式换行。只能用于行尾。 +例如,可用于防止两个连续空行被合并为一个段落,如下所示: +[[code]]a +_ +_ +b[[/code]] + +此外,{{@@_@@}} 允许在原本不支持换行的元素中插入换行(例如表格或列表)。 +为了正确生效,显式换行符必须与前面的文本或元素之间用空格分隔。 + +* {{@@~~~@@}}、{{@@~~~<@@}}、{{@@~~~>@@}} — 清除浮动元素。 +仅在行首使用时有效。 +分别等同于: +{{@@[[div style="clear: both"]][/div]]@@}} +{{@@[[div style="clear: left"]][[/div]]@@}} +{{@@[[div style="clear: right"]][[/div]]@@}} + +++ [[# headers]] 标题 + +{{@@+ 文本@@}}、 +{{@@++ 文本@@}}、 +{{@@+++ 文本@@}}、 +{{@@++++ 文本@@}}、 +{{@@+++++ 文本@@}}、 +{{@@++++++ 文本@@}} — 分别对应一级至六级标题。 + +不支持六级以上标题。 + +该元素只能在新行使用。 +内部不支持换行,但可以嵌入块级元素或显式换行,例如: + +[[code]]++ 第一行 _ +第二行[[/code]] + +在标题文本前可以添加符号 {{*}},例如 {{@@++* 文本@@}}。 +此时该标题不会被加入目录。 + +++ 水平线 + +{{@@---@@}} — 添加水平分隔线。 + +该元素只能在新行使用。 +前三个 {{-}} 之后的数量没有限制。 + +++ 引用 + +以下语法会创建两个嵌套的引用块: + +[[code]] +> 第一级 +>> 第二级 +>> 第二级的第二行 +> 回到第一级 +[[/code]] + +由于 {{@@[[blockquote]]@@}} 具有更好的可读性(和可编辑性),因此不推荐使用此语法。 + +++ 列表 + +网站支持三种列表类型:有序列表、无序列表和字典列表。 +前两种可以相互嵌套。 + +示例:无序列表中嵌套有序列表: + +[[code]] +* 元素1 +* 元素2 + # 元素2.1 + # 元素2.2 +* 元素3 +[[/code]] + +列表的嵌套级别由 {{*}} 或 {{#}} 前的空格或制表符数量决定。 + +列表项中可以通过 {{_}} 或使用 {{@@[[span]]@@}} 包裹内容来实现多行,例如: + +[[code]] +* 元素1 _ +下一行 +* [[span]]元素2 +下一行[[/span]] +[[/code]] + +字典列表定义如下: + +[[code]] +: 术语1 : 定义 +: 术语2 : 定义 +: 术语3 : 定义 +[[/code]] + +在术语或定义中同样可以使用 {{_}} 或 {{@@[[span]]@@}}。 + +++ 表格 + +简化(自由)语法的表格如下所示: + +[[code]] +||~ 标题 ||~ 标题2 || +||> 右对齐文本 ||= 居中文本 || +|||| 横向跨越两列的单元格 || +[[/code]] + +要在单元格中添加多行文本,可以使用 {{_}} 或 {{@@[[span]]@@}}。 + +该元素无法创建跨越多行(纵向合并)的单元格。在这种情况下,可以使用块级元素 {{@@[[table]]@@}},它不受此限制。 + +++ 其他 + +* {{@@[[# anchor]]@@}} — 创建一个具有指定标识符的元素,从而可以通过链接跳转到该位置(例如:{{@@[#anchor 跳转到锚点]@@}})。 + 该元素在视觉上类似于块级元素,但实际上并不是。 + 在元素定义中,{{#}} 后必须保留一个空格。 + +* {{@@[!-- 注释 --]@@}} — 定义一段在最终页面显示时不会呈现的源代码区域。 + 可用于在代码中添加技术性备注。 + [!-- 我就知道你会看到这里。 --] + +* [[# literals]]{{@<@@>@文本@<@@>@}} — 阻止 {{@<@@>@}} 包裹的内容被当作标记语法解析。 + 始终生成一行文本(字面量)。 + 可用于在出于视觉效果使用标记符号时避免歧义(例如,并非链接用途的单个方括号)。 + 也可用于破坏自动替换语法(当不希望发生自动替换时),例如: + {{%%pat@<@@>@h|param%%}} 始终会显示为文本 %%pat@@@@h|param%%,即便指定了参数 {{param}}。 + 也可用于像 {{_}} 那样创建空行(但不推荐这样使用)。 + +* {{@<@<>@—©@<>@>@}} — 插入指定的 HTML 实体符号(或多个符号)。 + +* 符号 «、» 和 — 的替换 +_ +_ +由于这些替换不属于自动替换,因此在仅支持纯文本的场景下不会生效(例如块属性或链接名称中)。 + + * {{@@ <<@@}} — 左引号:« + + * {{@@ >>@@}} — 右引号:» + + * {{@@ --@@}} — 长破折号:— + 需要注意的是,为了使该元素被解析为破折号而不是删除线语法,其两侧必须有空格。 + ++ 块级元素 + +所有块级元素都遵循类似规则构建。 + +每个块级元素都有名称(例如 {{div}}、{{iftags}}、{{blockquote}} 等)、可选的标识符,以及一个起始标签(例如 {{@@[[div]]@@}})。 +可以包含文本或其他块级元素的块级元素,还必须有与起始标签对应名称的结束标签(例如 {{@@[[/div]]@@}})。 + +某些块可以使用修饰符 {{_}}。 +该修饰符写在块名称之后,用于阻止在块内自动创建段落(文本会直接放入块中,换行通过 {{
      }} 实现)。 +修饰符只写在起始标签中,因此以下语法是正确的: +[[code]][[div_]]text[[/div]][[/code]] + +块的标识符是可选文本,写在块名称之后(但在修饰符 {{_}} 之前),通过 {{:}} 指定。例如: + +[[code]] +[[module:lu ListUsers]] + [[module CSS]] + body { + background: url(%%avatar%%); + } + [[/module]] +[[/module:lu]] +[[/code]] + +块标识符允许在接受文本内容的块(如 {{@@[[code]]@@}}、{{@@[[module]]@@}}、{{@@[[html]]@@}})内部使用该块的标准结束标签,而不会真正关闭它。 +这样可以将多个模块相互嵌套,或在该块内部写出 {{@@[[code]]@@}} 的示例: + +[[code:outer]] +[[code:2]] + [[code:b]] + 示例:使用块 [[cоde]] + [[/code:b]] +[[/code:2]] +[[/code:outer]] + +大多数块可以以某种形式接受属性(要么是 HTML 属性,要么是特定块自定义属性)。 +属性写法为 {{参数=值}} 或 {{参数="值"}}。 +不同于 HTML,在属性值中使用特殊字符时,不使用 HTML 实体(如 {{"}}),而是通过 {{\}} 转义: +例如 {{@@[[collapsible show="协议 \"忧郁\""]]@@}}。 + +++ [[# html-attributes]] 标准 HTML 属性 + +某些元素(例如 {{@@[[a]]@@}}、{{@@[[span]]@@}} 等)是 HTML 的直接接口, +其标记中的属性会直接插入生成的 HTML 页面中。 + +并非所有 HTML 属性都允许在标记中使用。 +允许使用的属性列表如下;更多详情请参阅 +https://www.w3schools.com/tags/ref_attributes.asp 的 HTML 文档。 +在本网站语境中最常用的属性已用 ##red|红色## 标出。 + +* {{##red|alt##}} +* {{##red|class##}} +* {{##red|colspan##}} +* {{##red|href##}} +* {{##red|id##}}:该属性会被特殊处理。标识符必须以 {{u-}} 作为前缀。如果未指定此前缀,系统会自动添加。例如,{{id="myid"}} 将会被转换为 {{id="u-myid"}}。 +* {{##red|rowspan##}} +* {{##red|style##}} +* {{##red|target##}} +* {{accept}} +* {{align}} +* {{autocapitalize}} +* {{autoplay}} +* {{background}} +* {{bgcolor}} +* {{border}} +* {{buffered}} +* {{checked}} +* {{cite}} +* {{cols}} +* {{contenteditable}} +* {{controls}} +* {{coords}} +* {{datetime}} +* {{decoding}} +* {{default}} +* {{dir}} +* {{dirname}} +* {{disabled}} +* {{download}} +* {{draggable}} +* {{for}} +* {{form}} +* {{headers}} +* {{height}} +* {{hidden}} +* {{high}} +* {{hreflang}} +* {{inputmode}} +* {{ismap}} +* {{itemprop}} +* {{kind}} +* {{label}} +* {{lang}} +* {{list}} +* {{loop}} +* {{low}} +* {{max}} +* {{maxlength}} +* {{min}} +* {{minlength}} +* {{multiple}} +* {{muted}} +* {{name}} +* {{optimum}} +* {{pattern}} +* {{placeholder}} +* {{poster}} +* {{preload}} +* {{readonly}} +* {{required}} +* {{reversed}} +* {{role}} +* {{rows}} +* {{scope}} +* {{selected}} +* {{shape}} +* {{size}} +* {{sizes}} +* {{span}} +* {{spellcheck}} +* {{src}} +* {{srclang}} +* {{srcset}} +* {{start}} +* {{step}} +* {{tabindex}} +* {{title}} +* {{translate}} +* {{type}} +* {{usemap}} +* {{value}} +* {{width}} +* {{wrap}} +* {{scrolling}} +* {{frameborder}} + +++ [[# booleans]] 布尔属性 + +文档中标记为可接受布尔值的属性,在实际使用中可以用以下字符串表示: + +True: + +* {{true}} +* {{t}} +* {{1}} +* {{yes}} + +False: + +* {{false}} +* {{f}} +* {{0}} +* {{no}} + +同样适用于以下内置 HTML 属性: + +* {{allowfullscreen}} +* {{allowpaymentrequest}} +* {{async}} +* {{autofocus}} +* {{autoplay}} +* {{checked}} +* {{controls}} +* {{default}} +* {{disabled}} +* {{formnovalidate}} +* {{hidden}} +* {{ismap}} +* {{itemscope}} +* {{loop}} +* {{multiple}} +* {{muted}} +* {{nomodule}} +* {{novalidate}} +* {{open}} +* {{playsinline}} +* {{readonly}} +* {{required}} +* {{reversed}} +* {{selected}} +* {{truespeed}} + +++ {{@@[[<]]@@}}、{{@@[[>]]@@}}、{{@@[[=]]@@}}、{{@@[[==]]@@}}:对齐 + +: 类型 : 全宽 +: 段落 : ✅ +: 支持属性 : ❌ + +* {{@@[[<]]@@}} — 将内部文本左对齐。 + +* {{@@[[>]]@@}} — 将内部文本右对齐。 + +* {{@@[[=]]@@}} — 将内部文本居中对齐。 + +* {{@@[[==]]@@}} — 将内部文本两端对齐。 + +++ {{@@[[a]]@@}}:链接 + +: 类型 : 行内 +: 别名 : {{@@[[anchor]]@@}} +: 支持 [#html-attributes HTML 属性] : ✅ +: 支持 {{*}} : ✅ +: 支持 {{_}} : ✅ + +使用修饰符 {{*}}(例如 {{@@[[*a href="https://google.com"]]Google[[/a]]@@}})等同于使用 {{target="_blank"}};该链接会在新窗口(标签页)中打开。 +若同时使用该修饰符和 {{target}},其值将会叠加。 + +通过该元素创建的链接会经过 [#link-handling 标准过滤]。 + +++ {{@@[[blockquote]]@@}}:引用块 + +: 类型 : 全宽 +: 段落 : ✅ +: 别名 : {{@@[[quote]]@@}} +: 支持 [#html-attributes HTML 属性] : ✅ + +在功能上等同于使用 {{>}},但在标记层面更加“干净”和易读。 + +++ {{@@[[b]]@@}}:加粗文本 + +: 类型 : 行内 +: 别名 : {{@@[[bold]]@@}}、{{@@[[strong]]@@}} +: 支持 [#html-attributes HTML 属性] : ✅ + +++ {{@@[[char]]@@}}:HTML 字符 + +: 类型 : 行内 +: 别名 : {{@@[[character]]@@}} +: 支持属性 : ❌ + +在文本中插入一个 HTML 实体字符。 +其工作方式与 {{@<@<>@@<>@>@}} 语法几乎相同。 + +使用示例:{{@@[[char —]]@@}} + +++ {{@@[[code]]@@}}:代码块 + +: 类型 : 全宽 +: 段落 : ❌ +: 支持属性 : ✅ + +允许忽略 {{@@[[code]]@@}} 与 {{@@[[/code]]@@}} 之间的标记规则。 +主要用于展示标记示例而不被立即解析。 + +该元素也可用于代码高亮。 + +通过该元素添加到页面的代码,可以通过如下格式的单独文件访问: + +{{@@https@@://files.projwikit.unitreaty.org/local@@--@@code/<页面名称>/<页面中的代码块编号,从 1 开始>}} + +对于 HTML、JavaScript、XML、CSS 语言,将设置对应的 MIME 类型, +从而可以在 {{` + p.SiteIcon = `-/sites/it's.png` + p.Title = `A & B ` + p.OGTitle = `A & B ` + p.OGDescription = `it's "quoted" & ` + p.OGURL = "https://wiki.example/main?a=1&b=2" + p.ThemeURL = "/-/theme/dark.css?v=1&x=2" + p.Breadcrumbs = []crumbSpec{ + {URL: "/a?x=1&y=2", Title: `it's `}, + {URL: "/b", Title: `"last" & final`}, + } + p.TagCategories = []tagCategorySpec{ + {Name: `cat & "co"`, Tags: []tagSpec{{Name: "a&b", FullName: "x:a&b"}}}, + } + p.LoginStatusConfig = `{"user": {"name": "it's "}}` + p.OptionsConfig = `{"pageId": "a&b"}` + }), + withPage("noindex", func(p *pageSpec) { p.NoIndex = true }), + withPage("google_tag", func(p *pageSpec) { p.GoogleTagID = "G-AB1'2-3" }), + withPage("one_breadcrumb", func(p *pageSpec) { p.Breadcrumbs = crumbs[:1] }), + withPage("two_breadcrumbs", func(p *pageSpec) { p.Breadcrumbs = crumbs[:2] }), + withPage("hidden_tags_only", func(p *pageSpec) { + p.TagCategories = []tagCategorySpec{ + {Name: "attribute", Tags: []tagSpec{{Name: "_hidden", FullName: "attribute:_hidden"}}}, + } + }), + withPage("unpadded_date", func(p *pageSpec) { + p.RevNumber = 0 + p.UpdatedAt = "2026-01-05T09:07:00+08:00" + }), + withPage("date_crosses_midnight_in_utc", func(p *pageSpec) { + p.RevNumber = 3 + p.UpdatedAt = "2026-03-01T00:30:00+08:00" + }), + withPage("unicode", func(p *pageSpec) { + p.SiteName = "维基" + p.SiteHeadline = "一个维基站点" + p.SiteTitle = "首页 - 维基" + p.Title = "首页" + p.TagCategories = []tagCategorySpec{ + {Name: "分类", Tags: []tagSpec{{Name: "中文标签", FullName: "分类:中文标签"}}}, + } + }), + {Name: "not_found", Kind: "not_found", PageID: "no-such-page"}, + {Name: "not_found_escaped", Kind: "not_found", PageID: `a&b'd'`}, + { + Name: "not_found_create", + Kind: "not_found", + PageID: "new-page", + AllowCreate: true, + Options: `{"page_id": "new-page", "pathParams": {"a": "b"}}`, + }, + {Name: "forbidden", Kind: "forbidden", PageID: "secret:page"}, + {Name: "forbidden_escaped", Kind: "forbidden", PageID: `a&b'd'`}, + }, + } +} + +func dataFor(t *testing.T, p *pageSpec) Data { + t.Helper() + d := Data{ + SiteName: p.SiteName, + SiteHeadline: p.SiteHeadline, + SiteTitle: p.SiteTitle, + SiteIcon: p.SiteIcon, + OGTitle: p.OGTitle, + OGDescription: p.OGDescription, + OGImage: p.OGImage, + OGURL: p.OGURL, + NoIndex: p.NoIndex, + GoogleTagID: p.GoogleTagID, + ThemeURL: p.ThemeURL, + ComputedStyle: p.ComputedStyle, + NavTop: p.NavTop, + NavSide: p.NavSide, + Title: p.Title, + Content: p.Content, + RevNumber: p.RevNumber, + LoginStatusConfig: p.LoginStatusConfig, + OptionsConfig: p.OptionsConfig, + } + for _, c := range p.Breadcrumbs { + d.Breadcrumbs = append(d.Breadcrumbs, Breadcrumb{URL: c.URL, Title: c.Title}) + } + for _, c := range p.TagCategories { + cat := TagCategory{Name: c.Name} + for _, tag := range c.Tags { + cat.Tags = append(cat.Tags, Tag{Name: tag.Name, FullName: tag.FullName}) + } + d.TagCategories = append(d.TagCategories, cat) + } + if p.UpdatedAt != "" { + at, err := time.Parse(time.RFC3339, p.UpdatedAt) + if err != nil { + t.Fatalf("Parse(%q) err = %v, want nil", p.UpdatedAt, err) + } + d.UpdatedAt = at + d.TimeZone = time.FixedZone("Asia/Shanghai", 8*60*60) + } + return d +} + +func render(t *testing.T, r *Renderer, c caseSpec) string { + t.Helper() + switch c.Kind { + case "page": + var b strings.Builder + if err := r.Page(&b, dataFor(t, c.Page)); err != nil { + t.Fatalf("Page(%s) err = %v, want nil", c.Name, err) + } + return b.String() + case "not_found": + html, err := r.NotFound(NotFound{PageID: c.PageID, AllowCreate: c.AllowCreate, Options: c.Options}) + if err != nil { + t.Fatalf("NotFound(%s) err = %v, want nil", c.Name, err) + } + return html + case "forbidden": + html, err := r.Forbidden(c.PageID) + if err != nil { + t.Fatalf("Forbidden(%s) err = %v, want nil", c.Name, err) + } + return html + } + t.Fatalf("case %s has kind %q, want one of page, not_found, forbidden", c.Name, c.Kind) + return "" +} + +func TestRenderMatchesGolden(t *testing.T) { + c := corpus() + r := testRenderer(t) + + var b strings.Builder + for _, spec := range c.Cases { + fmt.Fprintf(&b, "=== %s\n%s\n", spec.Name, render(t, r, spec)) + } + got := b.String() + + if *update { + writeCorpus(t, c) + if err := os.WriteFile(filepath.FromSlash(goldenPath), []byte(got), 0o644); err != nil { + t.Fatalf("WriteFile(%s) err = %v, want nil", goldenPath, err) + } + return + } + + want, err := os.ReadFile(filepath.FromSlash(goldenPath)) + if err != nil { + t.Fatalf("ReadFile(%s) err = %v, want nil", goldenPath, err) + } + if got != string(want) { + gotAt, wantAt := firstDiff(got, string(want)) + t.Errorf("render = %q, want %q", gotAt, wantAt) + } +} + +func firstDiff(got, want string) (string, string) { + for i := 0; i < len(got) && i < len(want); i++ { + if got[i] != want[i] { + return excerpt(got, i), excerpt(want, i) + } + } + return excerpt(got, min(len(got), len(want))), excerpt(want, min(len(got), len(want))) +} + +func excerpt(s string, at int) string { + start := max(0, at-40) + end := min(len(s), at+40) + return s[start:end] +} + +func writeCorpus(t *testing.T, c corpusFile) { + t.Helper() + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + t.Fatalf("Marshal(corpus) err = %v, want nil", err) + } + if err := os.WriteFile(filepath.FromSlash(corpusPath), append(data, '\n'), 0o644); err != nil { + t.Fatalf("WriteFile(%s) err = %v, want nil", corpusPath, err) + } +} + +func profileTestRenderer(t *testing.T) *Renderer { + t.Helper() + bundle, err := i18n.Load("") + if err != nil { + t.Fatalf("i18n.Load() err = %v, want nil", err) + } + return New(bundle.Localizer(i18n.DefaultLanguage), static.NewAssets(nil)) +} + +func TestProfileFeedRendersOneRowPerItem(t *testing.T) { + at := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + got, err := profileTestRenderer(t).Profile(Profile{ + DisplayName: "probe-author", + JoinedAt: at, + Edits: ProfileFeed{Items: []ProfileItem{ + {URL: "/scp-173", Title: "SCP-173", Site: "Test Wiki", At: at, + Flags: []ProfileFlag{{ID: "S", Desc: "source"}}, Comment: "typo"}, + {URL: "/component:box", Title: "Box", Site: "Test Wiki", At: at}, + }}, + }) + if err != nil { + t.Fatalf("Profile() err = %v, want nil", err) + } + if n := strings.Count(got, `Box`) { + t.Error(`Profile() does not link "Box" to /component:box`) + } + if !strings.Contains(got, `>Test Wiki<`) { + t.Error(`Profile() does not name the site a row came from`) + } + if !strings.Contains(got, `S`) { + t.Error(`Profile() does not flag what an edit changed`) + } + if !strings.Contains(got, "typo") { + t.Error(`Profile() does not show an edit comment`) + } +} + +func TestProfileFeedFallsBackToTheEmptyLine(t *testing.T) { + got, err := profileTestRenderer(t).Profile(Profile{DisplayName: "probe-author"}) + if err != nil { + t.Fatalf("Profile() err = %v, want nil", err) + } + if n := strings.Count(got, `class="empty"`); n != 2 { + t.Errorf("count of empty feeds = %d, want 2", n) + } + if strings.Contains(got, `

        `) { + t.Error("Profile() renders a feed list with no items") + } +} + +func TestProfileEditCarriesTheToken(t *testing.T) { + got, err := profileTestRenderer(t).ProfileEdit(ProfileEdit{ + DisplayName: "probe-author", CSRF: "tok", FullName: "Ada Lovelace", + }) + if err != nil { + t.Fatalf("ProfileEdit() err = %v, want nil", err) + } + if !strings.Contains(got, `name="csrfmiddlewaretoken" value="tok"`) { + t.Error("ProfileEdit() does not carry the csrf token") + } + if !strings.Contains(got, `enctype="multipart/form-data"`) { + t.Error("ProfileEdit() posts a form that cannot carry a file") + } + if strings.Contains(got, `class="error-inline"`) { + t.Error("ProfileEdit() shows an error block with no error") + } +} + +func TestProfileEditMarksTheChosenLanguage(t *testing.T) { + got, err := profileTestRenderer(t).ProfileEdit(ProfileEdit{ + DisplayName: "probe-author", + Language: "en", + Languages: []i18n.Choice{{Tag: "zh-hans", Name: "简体中文"}, {Tag: "en", Name: "English"}}, + }) + if err != nil { + t.Fatalf("ProfileEdit() err = %v, want nil", err) + } + for _, want := range []string{``, ``} { + if !strings.Contains(got, want) { + t.Errorf("Contains(ProfileEdit(), %q) = false, want true", want) + } + } +} + +func TestProfileEditShowsTheProblem(t *testing.T) { + got, err := profileTestRenderer(t).ProfileEdit(ProfileEdit{ + DisplayName: "probe-author", Error: "too big", + }) + if err != nil { + t.Fatalf("ProfileEdit() err = %v, want nil", err) + } + if !strings.Contains(got, "too big") { + t.Error("ProfileEdit() does not show the error it was given") + } +} diff --git a/internal/shell/templates/accept.html b/internal/shell/templates/accept.html new file mode 100644 index 00000000..ab130ce4 --- /dev/null +++ b/internal/shell/templates/accept.html @@ -0,0 +1,35 @@ +{{define "accept.html"}} +