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')}
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 }) => {
-
-
+
+
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 && (
- | 如何操作? |
+ {t('articles.delete.how-label')} |
= ({ pageId, onClose, canDelete, canRename
checked={!permanent}
disabled={loading || saving || !canRename}
/>
-
+
|
@@ -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 ? (
) : (
)}
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')}
|
- 版本 {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')}
}