~cytrogen/srht-deploy

098f0ded66b5f614ec99f0d3efb89b3a5b1bc0f6 — Cytrogen 10 days ago
初始提交:sourcehut Docker Compose 自托管部署

基于 k8ieone 镜像的定制部署,包含:
- meta/git/todo/hub 四个服务的 Dockerfile 及补丁
- Jinja2 do 扩展修复、OAuth API 兼容层
- meta 模板汉化
79 files changed, 4676 insertions(+), 0 deletions(-)

A .env.example
A .gitignore
A README.md
A compose.yaml
A config/config.ini.example
A git-custom/Dockerfile
A git-custom/fix_jinja2_do.py
A git-custom/sshd_config_patch
A hub-custom/Dockerfile
A hub-custom/fix_jinja2_do.py
A init-databases.sh
A meta-custom/Dockerfile
A meta-custom/base-templates/graphql.html
A meta-custom/base-templates/internal_error.html
A meta-custom/base-templates/layout-full.html
A meta-custom/base-templates/layout.html
A meta-custom/base-templates/nav.html
A meta-custom/base-templates/not_found.html
A meta-custom/base-templates/oauth-error.html
A meta-custom/base-templates/pagination.html
A meta-custom/base-templates/read_only.html
A meta-custom/base-templates/suspended.html
A meta-custom/base-templates/unauthorized.html
A meta-custom/fix_jinja2_do.py
A meta-custom/templates/already-confirmed.html
A meta-custom/templates/are-you-sure.html
A meta-custom/templates/audit-log.html
A meta-custom/templates/billing-change-period.html
A meta-custom/templates/billing-complete.html
A meta-custom/templates/billing-initial.html
A meta-custom/templates/billing-invoice.html
A meta-custom/templates/billing.html
A meta-custom/templates/client-admin.html
A meta-custom/templates/client-delete.html
A meta-custom/templates/client-security.html
A meta-custom/templates/client-settings.html
A meta-custom/templates/client-tabs.html
A meta-custom/templates/forgot.html
A meta-custom/templates/index.html
A meta-custom/templates/keys.html
A meta-custom/templates/login.html
A meta-custom/templates/meta.html
A meta-custom/templates/new-payment.html
A meta-custom/templates/oauth-authorize.html
A meta-custom/templates/oauth-error.html
A meta-custom/templates/oauth-oob.html
A meta-custom/templates/oauth-personal-token.html
A meta-custom/templates/oauth-register.html
A meta-custom/templates/oauth-registered.html
A meta-custom/templates/oauth.html
A meta-custom/templates/oauth2-authorization.html
A meta-custom/templates/oauth2-client-registered.html
A meta-custom/templates/oauth2-dashboard.html
A meta-custom/templates/oauth2-error.html
A meta-custom/templates/oauth2-manage-client.html
A meta-custom/templates/oauth2-personal-token-issued.html
A meta-custom/templates/oauth2-personal-token-registration.html
A meta-custom/templates/oauth2-register-client.html
A meta-custom/templates/privacy.html
A meta-custom/templates/profile-delete.html
A meta-custom/templates/profile-deleted.html
A meta-custom/templates/profile.html
A meta-custom/templates/register-step2.html
A meta-custom/templates/register.html
A meta-custom/templates/registered.html
A meta-custom/templates/reset.html
A meta-custom/templates/security.html
A meta-custom/templates/tabs.html
A meta-custom/templates/totp-challenge.html
A meta-custom/templates/totp-enable.html
A meta-custom/templates/totp-enabled.html
A meta-custom/templates/totp-recovery.html
A meta-custom/templates/user.html
A meta-custom/templates/users.html
A todo-custom/Dockerfile
A todo-custom/compat_oauth.py
A todo-custom/fix_jinja2_do.py
A todo-custom/nginx.conf
A todo-custom/start.sh
A  => .env.example +2 -0
@@ 1,2 @@
# PostgreSQL root password
POSTGRES_PASSWORD=changeme_to_a_strong_password

A  => .gitignore +11 -0
@@ 1,11 @@
# 实际配置文件(含密钥/密码)
config/config.ini
.env

# PGP 密钥
config/*.priv
config/*.pub

# Docker 数据 / 运行时
postgres_data/
postgres_sh/

A  => README.md +141 -0
@@ 1,141 @@
# sourcehut 自托管部署

基于 Docker Compose 的 [sourcehut](https://sr.ht) 自托管部署,运行于 `*.cytrogen.icu`。

## 架构

```
                    ┌─────────────────────────────────────────┐
                    │           Docker Compose 网络            │
                    │                                         │
  :22222 ──────────►│  ┌─────┐   ┌──────┐   ┌──────┐         │
  (SSH git push)    │  │ git │──►│      │   │      │         │
                    │  └─────┘   │      │   │      │         │
  :5001 ───────────►│  ┌──────┐  │ post │   │      │         │
  (meta web)        │  │ meta │──►│ gres │   │redis │         │
                    │  └──────┘  │      │   │      │         │
  :5002 ───────────►│  ┌─────┐   │      │   │      │         │
  (hub web)         │  │ hub │──►│      │   │      │         │
                    │  └─────┘   │      │   │      │         │
  :5003 ───────────►│  ┌──────┐  │      │   │      │         │
  (git web)         │  │      │──►│      │──►│      │         │
                    │  └──────┘  └──────┘   └──────┘         │
  :5004 ───────────►│  ┌──────┐                               │
  (todo web)        │  │ todo │──►postgres + redis            │
                    │  └──────┘                               │
                    └─────────────────────────────────────────┘

6 个服务:
  postgres  — PostgreSQL 16 (Alpine),4 个数据库(meta/git/todo/hub)
  redis     — Redis 7 (Alpine),48MB 内存限制
  meta      — 用户认证与账户管理  (meta.cytrogen.icu)
  git       — Git 仓库托管 + SSH  (git.cytrogen.icu)
  todo      — 工单/Issue 跟踪     (todo.cytrogen.icu)
  hub       — 项目聚合门户        (hub.cytrogen.icu)
```

所有 Web 服务绑定 `127.0.0.1`,通过前端反向代理(Caddy/nginx)对外提供 HTTPS。仅 git SSH 端口 (`22222`) 对外开放。

## 定制修改及原因

本部署基于 [k8ieone 的 sourcehut Docker 镜像](https://github.com/k8ieone/sourcehut-docker),但这些镜像存在若干问题需要修复。以下是每个定制目录的修改说明。

### `meta-custom/`

| 修改 | 原因 |
|------|------|
| Jinja2 `{% do %}` 扩展修复 | 上游 core.sr.ht 已合并修复([patch 39036](https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/39036))但 k8ieone 镜像未包含 |
| `templates/` — 模板汉化 | 将 meta.sr.ht 全部用户界面翻译为中文 |
| `base-templates/` — 基础模板汉化 | 汉化导航栏、错误页面等公共模板 |

### `git-custom/`

| 修改 | 原因 |
|------|------|
| 安装 openssh-server | 镜像不含 SSH 服务器,无法 `git push` |
| SSH key dispatch 配置 | 配置 `sshd` 使用 `gitsrht-dispatch` 进行公钥认证 |
| 安装 pgpy | git.sr.ht 运行时依赖,镜像缺失 |
| libgit2 属主修复 | gunicorn 以 root 运行但 SSH 推送创建的仓库属于 git 用户,libgit2 (pygit2) 拒绝 root 读取非 root 仓库。启动时对仓库顶层目录 `chown root:git` 解决 |
| Jinja2 `{% do %}` 修复 | 同 meta-custom |

### `hub-custom/`

| 修改 | 原因 |
|------|------|
| 安装 pgpy | hub.sr.ht 运行时依赖,镜像缺失 |
| Jinja2 `{% do %}` 修复 | 同 meta-custom |

### `todo-custom/`

todo.sr.ht 是最复杂的定制——k8ieone **不提供预构建的 todo 镜像**,需要从源码完整构建。

| 修改 | 原因 |
|------|------|
| 多阶段 Dockerfile(从源码构建) | k8ieone 无 todo.sr.ht 预构建镜像,使用 [sr.ht-apkbuilds](https://git.sr.ht/~sircmpwn/sr.ht-apkbuilds) 从源码编译 |
| `compat_oauth.py` — OAuth API 兼容层 | core.sr.ht 0.78.6 重命名了 OAuth API(`AbstractOAuthService` → `OAuthService`、`DelegatedScope` → `OAuthScope`、`SrhtFlask` 不再接受 `oauth_service=`),而 todo.sr.ht 0.77.5 仍使用旧 API |
| kombu 版本升级 | Celery 5.5.3 要求 `kombu>=5.4`,但 Alpine 3.20 仅提供 5.3.7 |
| Jinja2 `{% do %}` 修复 | 同 meta-custom |
| `nginx.conf` + `start.sh` | 容器入口:启动 gunicorn (web)、GraphQL API、Celery worker、nginx 反代 |

### 公共补丁脚本

- **`fix_jinja2_do.py`**(4 份副本,各服务目录各一份):运行时修补 `srht/flask.py`,在 `ChoiceLoader` 之后添加 `jinja2.ext.do` 扩展。上游已修复但 k8ieone 镜像未包含此修复。
- **`compat_oauth.py`**(仅 `todo-custom/`):为 core.sr.ht 0.78.6 + todo.sr.ht 0.77.5 版本差异添加兼容层。

## 快速开始

```bash
# 1. 克隆仓库
git clone ssh://git@git.cytrogen.icu:22222/~cytrogen/srht-deploy
cd srht-deploy

# 2. 创建配置文件
cp .env.example .env
cp config/config.ini.example config/config.ini

# 3. 编辑配置
#    - .env: 设置 PostgreSQL root 密码
#    - config/config.ini: 修改下方「配置说明」中列出的字段
vi .env
vi config/config.ini

# 4. 生成密钥(需要在 meta 容器内执行)
#    先启动 meta 服务以获取 srht-keygen 工具
docker compose up -d meta
docker compose exec meta srht-keygen service  # → service-key
docker compose exec meta srht-keygen network  # → network-key
docker compose exec meta srht-keygen webhook  # → webhooks private-key
#    将生成的密钥填入 config/config.ini

# 5. 将 init-databases.sh 放入 postgres 初始化目录
mkdir -p postgres_sh
cp init-databases.sh postgres_sh/

# 6. 启动所有服务
docker compose up -d

# 7. 创建管理员账户
docker compose exec meta metasrht-manageuser -t admin -e YOUR_EMAIL USERNAME
```

## 配置说明

`config/config.ini.example` 中需要修改的关键字段:

| 字段 | 说明 |
|------|------|
| `[sr.ht] service-key` | 会话加密密钥,用 `srht-keygen service` 生成 |
| `[sr.ht] network-key` | 内部通信密钥,用 `srht-keygen network` 生成 |
| `[mail] smtp-password` | SMTP 发信密码 |
| `[webhooks] private-key` | Webhook 签名密钥,用 `srht-keygen webhook` 生成 |
| `[*.sr.ht] oauth-client-id/secret` | 各服务的 OAuth 凭据,在 meta 注册后获取 |
| `[mail] pgp-key-id` | PGP 签名邮件的密钥 ID(可选) |

数据库连接字符串和 Redis 地址使用 Docker 服务名(`postgres`、`redis`),无需修改。

## 上游参考

- [k8ieone/sourcehut-docker](https://github.com/k8ieone/sourcehut-docker) — 本部署使用的 Docker 基础镜像
- [sr.ht-apkbuilds](https://git.sr.ht/~sircmpwn/sr.ht-apkbuilds) — Alpine 打包脚本(todo.sr.ht 从此构建)
- [Jinja2 do 扩展修复](https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/39036) — 上游补丁
- [sourcehut 官方文档](https://man.sr.ht/installation.md) — 安装参考

A  => compose.yaml +119 -0
@@ 1,119 @@
services:
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./postgres_sh:/docker-entrypoint-initdb.d
    deploy:
      resources:
        limits:
          memory: 256M
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --maxmemory 48mb --maxmemory-policy allkeys-lru
    deploy:
      resources:
        limits:
          memory: 64M
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  meta:
    build: ./meta-custom
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "127.0.0.1:5001:8080"
    volumes:
      - ./config:/etc/sr.ht
      - meta_data:/var/lib/meta.sr.ht
    deploy:
      resources:
        limits:
          memory: 256M

  git:
    build: ./git-custom
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      meta:
        condition: service_started
    ports:
      - "127.0.0.1:5003:8080"
      - "0.0.0.0:22222:22"
    volumes:
      - ./config:/etc/sr.ht
      - git_data:/var/lib/git.sr.ht
      - git_repos:/var/lib/git
    deploy:
      resources:
        limits:
          memory: 256M

  todo:
    build: ./todo-custom
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      meta:
        condition: service_started
    ports:
      - "127.0.0.1:5004:8080"
    volumes:
      - ./config:/etc/sr.ht
      - todo_data:/var/lib/todo.sr.ht
    deploy:
      resources:
        limits:
          memory: 256M

  hub:
    build: ./hub-custom
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
      meta:
        condition: service_started
    ports:
      - "127.0.0.1:5002:8080"
    volumes:
      - ./config:/etc/sr.ht
    deploy:
      resources:
        limits:
          memory: 256M

volumes:
  postgres_data:
  meta_data:
  git_data:
  git_repos:
  todo_data:

A  => config/config.ini.example +220 -0
@@ 1,220 @@
[sr.ht]
#
# The name of your network of sr.ht-based sites
site-name=Cytrogen
#
# The top-level info page for your site
site-info=https://cytrogen.icu
#
# {{ site-name }}, {{ site-blurb }}
site-blurb=Cytrogen's code forge
#
# If this != production, we add a banner to each page
environment=production
#
# Contact information for the site owners
owner-name=Cytrogen
owner-email=boo@cytrogen.icu
#
# The source code for your fork of sr.ht
source-url=https://git.sr.ht/~sircmpwn/srht
#
# Link to your instance's privacy policy.
privacy-policy=https://man.sr.ht/privacy.md
#
# A key used for encrypting session cookies.
# Generate with: srht-keygen service
service-key=CHANGEME
#
# A secret key to encrypt internal messages with.
# Generate with: srht-keygen network
network-key=CHANGEME
#
# The redis host URL.
redis-host=redis://redis:6379/1
#
# Optional email address for reporting security-related issues.
security-address=boo@cytrogen.icu
#
# The global domain of the site.
global-domain=cytrogen.icu

[objects]
#
# Configure S3-compatible object storage for services. Optional.
s3-upstream=
s3-access-key=
s3-secret-key=

[mail]
#
# Outgoing SMTP settings
smtp-host=mail.cytrogen.icu
smtp-port=587
smtp-from=srht@cytrogen.icu
#
# Options: starttls, tls, insecure
smtp-encryption=starttls
#
# Options: plain, none
smtp-auth=plain
smtp-user=sourcehut
smtp-password=CHANGEME
#
# Application exceptions are emailed to this address
error-to=boo@cytrogen.icu
error-from=srht@cytrogen.icu
#
# PGP key for signing outgoing emails
pgp-privkey=/etc/sr.ht/email.priv
pgp-pubkey=/etc/sr.ht/email.pub
pgp-key-id=

[webhooks]
#
# base64-encoded Ed25519 key for signing webhook payloads.
# Generate with: srht-keygen webhook
private-key=CHANGEME

[meta.sr.ht]
#
# URL meta.sr.ht is being served at (protocol://domain)
origin=https://meta.cytrogen.icu
#
# Address and port to bind the debug server to
debug-host=0.0.0.0
debug-port=5000
#
# Database connection string
connection-string=postgresql://metasrht:metasrht@postgres/metasrht?sslmode=disable
#
# Set to "yes" to automatically run migrations on package upgrade.
migrate-on-upgrade=yes
#
# The redis connection used for the webhooks worker
webhooks=redis://redis:6379/1
#
# Send welcome emails after signup (requires cron)
welcome-emails=no
#
# Origin URL for the API
# 使用 Docker 服务名,供跨容器调用
api-origin=http://meta:5100

[meta.sr.ht::api]
max-complexity=200
max-duration=3s
# 需包含 Docker 内网,供其他容器调用 API
internal-ipnet=127.0.0.0/8,::1/128,192.168.0.0/16,10.0.0.0/8,172.0.0.0/8

[meta.sr.ht::settings]
#
# If "no", public registration will not be permitted.
registration=no
#
# Where to redirect new users upon registration
onboarding-redirect=https://meta.cytrogen.icu

[meta.sr.ht::aliases]

[meta.sr.ht::billing]
enabled=no
stripe-public-key=
stripe-secret-key=

[meta.sr.ht::auth]
# Options: builtin, unix-pam
#auth-method=builtin

[meta.sr.ht::auth::unix-pam]
email-default-domain=
#service=sshd
create-users=yes
user-group=
admin-group=wheel

[hub.sr.ht]
origin=https://hub.cytrogen.icu
debug-host=0.0.0.0
debug-port=5014
connection-string=postgresql://hubsrht:hubsrht@postgres/hubsrht?sslmode=disable
migrate-on-upgrade=yes
oauth-client-id=
oauth-client-secret=
api-origin=http://hub:5114

[todo.sr.ht]
#
# URL todo.sr.ht is being served at (protocol://domain)
origin=https://todo.cytrogen.icu
#
# Address and port to bind the debug server to
debug-host=0.0.0.0
debug-port=5003
#
# Database connection string
connection-string=postgresql://todosrht:todosrht@postgres/todosrht?sslmode=disable
#
# Set to "yes" to automatically run migrations on package upgrade.
migrate-on-upgrade=yes
#
# The redis connection used for the webhooks worker
webhooks=redis://redis:6379/1
#
# todo.sr.ht's OAuth client ID and secret for meta.sr.ht
oauth-client-id=
oauth-client-secret=
#
# Origin URL for the API
api-origin=http://127.0.0.1:5103

[todo.sr.ht::mail]
posting-domain=todo.cytrogen.icu

[todo.sr.ht::api]
max-complexity=200
max-duration=3s
internal-ipnet=127.0.0.0/8,::1/128,192.168.0.0/16,10.0.0.0/8,172.0.0.0/8

[git.sr.ht]
#
# URL git.sr.ht is being served at (protocol://domain)
origin=https://git.cytrogen.icu
#
# Address and port to bind the debug server to
debug-host=0.0.0.0
debug-port=5001
#
# Database connection string
connection-string=postgresql://gitsrht:gitsrht@postgres/gitsrht?sslmode=disable
#
# Set to "yes" to automatically run migrations on package upgrade.
migrate-on-upgrade=yes
#
# The redis connection used for the webhooks worker
webhooks=redis://redis:6379/1
#
# A post-update script which is installed in every git repo.
post-update-script=/usr/bin/gitsrht-update-hook
#
# git.sr.ht's OAuth client ID and secret for meta.sr.ht
oauth-client-id=
oauth-client-secret=
#
# Path to git repositories on disk
repos=/var/lib/git/
#
# S3 object storage (optional)
s3-bucket=
s3-prefix=
#
# Required for preparing and sending patchsets from git.sr.ht
outgoing-domain=

[git.sr.ht::api]
max-complexity=200
max-duration=3s
internal-ipnet=127.0.0.0/8,::1/128,192.168.0.0/16,10.0.0.0/8,172.0.0.0/8

[git.sr.ht::dispatch]
/usr/bin/gitsrht-keys=git:git

A  => git-custom/Dockerfile +33 -0
@@ 1,33 @@
FROM ghcr.io/k8ieone/srht-git:latest

# --- 依赖修复 ---
# pgpy: git.sr.ht 需要但镜像缺失
# openssh-server: SSH push 支持
RUN apk add --no-cache py3-pip openssh-server \
    && pip3 install pgpy

# --- SSH 配置 ---
# 生成主机密钥
RUN ssh-keygen -A

# 解锁 git 用户(SSH 登录需要)
RUN echo 'git:x' | chpasswd

# 配置 sshd 使用 sourcehut 的 key dispatch
COPY sshd_config_patch /tmp/sshd_config_patch
RUN cat /tmp/sshd_config_patch >> /etc/ssh/sshd_config \
    && rm /tmp/sshd_config_patch

# 修复 start.sh 中 sshd 启动路径(Alpine 需要绝对路径)
RUN sed -i 's|sshd &|/usr/sbin/sshd \&|' /start.sh

# --- libgit2 属主检查修复 ---
# gunicorn 以 root 运行,但 SSH 推送创建的仓库属于 git 用户
# libgit2 (pygit2) 会拒绝 root 访问非 root 的仓库
# 在启动时将仓库顶层目录 chown 为 root(内部文件保持 git 用户,不影响 SSH 推送)
RUN sed -i '1a find /var/lib/git -mindepth 2 -maxdepth 2 -type d -exec chown root:git {} \\; -exec chmod g+ws {} \\; 2>/dev/null || true' /start.sh

# --- Jinja2 修复 ---
# 上游 core.sr.ht 已修复但此镜像未包含:启用 {% do %} 模板标签
COPY fix_jinja2_do.py /tmp/fix_jinja2_do.py
RUN python3 /tmp/fix_jinja2_do.py && rm /tmp/fix_jinja2_do.py

A  => git-custom/fix_jinja2_do.py +33 -0
@@ 1,33 @@
"""Enable Jinja2 'do' extension in sourcehut Flask app.

Upstream fix: https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/39036
"""
import glob

candidates = glob.glob("/usr/lib/python3.*/site-packages/srht/flask.py")
if not candidates:
    raise FileNotFoundError("Cannot find srht/flask.py")

source_file = candidates[0]

with open(source_file) as f:
    content = f.read()

target = "self.jinja_loader = ChoiceLoader"
patch = '        self.jinja_env.add_extension("jinja2.ext.do")'

if "jinja2.ext.do" not in content:
    lines = content.split("\n")
    patched = False
    for i, line in enumerate(lines):
        if target in line:
            lines.insert(i + 1, patch)
            patched = True
            break
    if not patched:
        raise RuntimeError(f"Could not find '{target}' in {source_file}")
    with open(source_file, "w") as f:
        f.write("\n".join(lines))
    print(f"Patched {source_file}: enabled jinja2.ext.do")
else:
    print(f"Already patched: {source_file}")

A  => git-custom/sshd_config_patch +4 -0
@@ 1,4 @@

# sourcehut git SSH key dispatch
AuthorizedKeysCommand /usr/bin/gitsrht-dispatch "%u" "%h" "%t" "%k"
AuthorizedKeysCommandUser root

A  => hub-custom/Dockerfile +9 -0
@@ 1,9 @@
FROM ghcr.io/k8ieone/srht-hub:latest

# pgpy: hub.sr.ht 需要但镜像缺失
RUN apk add --no-cache py3-pip \
    && pip3 install pgpy

# --- Jinja2 修复 ---
COPY fix_jinja2_do.py /tmp/fix_jinja2_do.py
RUN python3 /tmp/fix_jinja2_do.py && rm /tmp/fix_jinja2_do.py

A  => hub-custom/fix_jinja2_do.py +33 -0
@@ 1,33 @@
"""Enable Jinja2 'do' extension in sourcehut Flask app.

Upstream fix: https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/39036
"""
import glob

candidates = glob.glob("/usr/lib/python3.*/site-packages/srht/flask.py")
if not candidates:
    raise FileNotFoundError("Cannot find srht/flask.py")

source_file = candidates[0]

with open(source_file) as f:
    content = f.read()

target = "self.jinja_loader = ChoiceLoader"
patch = '        self.jinja_env.add_extension("jinja2.ext.do")'

if "jinja2.ext.do" not in content:
    lines = content.split("\n")
    patched = False
    for i, line in enumerate(lines):
        if target in line:
            lines.insert(i + 1, patch)
            patched = True
            break
    if not patched:
        raise RuntimeError(f"Could not find '{target}' in {source_file}")
    with open(source_file, "w") as f:
        f.write("\n".join(lines))
    print(f"Patched {source_file}: enabled jinja2.ext.do")
else:
    print(f"Already patched: {source_file}")

A  => init-databases.sh +20 -0
@@ 1,20 @@
#!/usr/bin/env bash
# PostgreSQL init script for sourcehut databases
# This runs automatically on first postgres container start
set -e

psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
    CREATE USER metasrht WITH PASSWORD 'metasrht';
    CREATE DATABASE metasrht OWNER metasrht;

    CREATE USER gitsrht WITH PASSWORD 'gitsrht';
    CREATE DATABASE gitsrht OWNER gitsrht;

    CREATE USER todosrht WITH PASSWORD 'todosrht';
    CREATE DATABASE todosrht OWNER todosrht;

    CREATE USER hubsrht WITH PASSWORD 'hubsrht';
    CREATE DATABASE hubsrht OWNER hubsrht;
EOSQL

echo "Databases metasrht, gitsrht, todosrht and hubsrht created."

A  => meta-custom/Dockerfile +10 -0
@@ 1,10 @@
FROM ghcr.io/k8ieone/srht-meta:latest

# --- Jinja2 修复 ---
# 上游 core.sr.ht 已修复但此镜像未包含:启用 {% do %} 模板标签
COPY fix_jinja2_do.py /tmp/fix_jinja2_do.py
RUN python3 /tmp/fix_jinja2_do.py && rm /tmp/fix_jinja2_do.py

# --- 模板汉化 ---
COPY templates/ /usr/lib/python3.10/site-packages/metasrht/templates/
COPY base-templates/ /usr/lib/python3.10/site-packages/srht/templates/

A  => meta-custom/base-templates/graphql.html +135 -0
@@ 1,135 @@
{% extends "layout-full.html" %}
{% block head %}
<link rel="stylesheet" href="/static/codemirror.css">
<style>
.CodeMirror {
  background: #e9ecef;
  height: 35rem;
}

.input textarea, .results pre {
  height: 35rem;
}

.highlight .err {
  /* work around outdated gql implementation in pygments */
  border: none !important;
}
</style>
{% endblock %}
{% block body %} 
<form class="container-fluid" id="query-form" method="POST">
  {{csrf_token()}}
  <noscript class="alert alert-info d-block">
    <strong>Notice:</strong> This page works without JavaScript, but the
    experience is improved if you enable it.
  </noscript>
  <div class="row">
    <div class="col-md-6 input">
      <textarea
        class="form-control"
        rows="25"
        id="editor"
        placeholder="Enter a GraphQL query here"
        name="query">{{query}}</textarea>
      <script>
        /* Reduce effects of FOUC for JS users */
        document.getElementById('editor').style.display = 'none';
      </script>
    </div>
    <div class="col-md-6 results">
      {{results}}
      <button class="btn btn-primary pull-right" type="submit">
        Submit query {{icon('caret-right')}}
      </button>
    </div>
  </div>
  <div class="row">
    <div class="col-md-12">
      <details style="margin-top: 1rem">
        <summary>View GraphQL schema</summary>
        {{schema}}
      </details>
    </div>
  </div>
</form>
{% endblock %}
{% block scripts %}
<script src="/static/codemirror.js"></script>
<script src="/static/simple.js"></script>
<script>
CodeMirror.defineSimpleMode("graphql", {
  start: [
    {regex: /"(?:[^\\]|\\.)*?(?:"|$)/, token: "string"},
    {regex: /#.*/, token: "comment"},
    {regex: /\w[a-zA-Z]+/, token: "atom"},
  ],
  meta: {
    lineComment: "#"
  }
});

const el = document.getElementById('editor');
let cm = CodeMirror(elt => {
  el.parentNode.replaceChild(elt, el);
}, {
  value: el.value,
  mode: 'graphql',
  lineNumbers: true,
});

document.querySelector('button[type="submit"]').addEventListener('click', ev => {
  ev.preventDefault();
  let form = document.getElementById('query-form');
  let node = document.createElement('input');
  node.type = 'hidden';
  node.name = 'query';
  node.value = cm.getValue();
  form.appendChild(node);
  form.submit();
});
</script>
<style>
@media(prefers-color-scheme: dark) {
  .cm-s-default.CodeMirror { background: #131618; color: white; }
  .cm-s-default div.CodeMirror-selected { background: #49483E; }
  .cm-s-default .CodeMirror-line::selection, .cm-s-default .CodeMirror-line > span::selection, .cm-s-default .CodeMirror-line > span > span::selection { background: rgba(73, 72, 62, .99); }
  .cm-s-default .CodeMirror-line::-moz-selection, .cm-s-default .CodeMirror-line > span::-moz-selection, .cm-s-default .CodeMirror-line > span > span::-moz-selection { background: rgba(73, 72, 62, .99); }
  .cm-s-default .CodeMirror-gutters { background: #272822; border-right: 0px; }
  .cm-s-default .CodeMirror-guttermarker { color: white; }
  .cm-s-default .CodeMirror-guttermarker-subtle { color: #d0d0d0; }
  .cm-s-default .CodeMirror-linenumber { color: #d0d0d0; }
  .cm-s-default .CodeMirror-cursor { border-left: 1px solid #f8f8f0; }

  .cm-s-default span.cm-comment { color: #75715e; }
  .cm-s-default span.cm-atom { color: #ae81ff; }
  .cm-s-default span.cm-number { color: #ae81ff; }

  .cm-s-default span.cm-comment.cm-attribute { color: #97b757; }
  .cm-s-default span.cm-comment.cm-def { color: #bc9262; }
  .cm-s-default span.cm-comment.cm-tag { color: #bc6283; }
  .cm-s-default span.cm-comment.cm-type { color: #5998a6; }

  .cm-s-default span.cm-property, .cm-s-default span.cm-attribute { color: #a6e22e; }
  .cm-s-default span.cm-keyword { color: #f92672; }
  .cm-s-default span.cm-builtin { color: #66d9ef; }
  .cm-s-default span.cm-string { color: #e6db74; }

  .cm-s-default span.cm-variable { color: #f8f8f2; }
  .cm-s-default span.cm-variable-2 { color: #9effff; }
  .cm-s-default span.cm-variable-3, .cm-s-default span.cm-type { color: #66d9ef; }
  .cm-s-default span.cm-def { color: #fd971f; }
  .cm-s-default span.cm-bracket { color: #f8f8f2; }
  .cm-s-default span.cm-tag { color: #f92672; }
  .cm-s-default span.cm-header { color: #ae81ff; }
  .cm-s-default span.cm-link { color: #ae81ff; }
  .cm-s-default span.cm-error { background: #f92672; color: #f8f8f0; }

  .cm-s-default .CodeMirror-activeline-background { background: #373831; }
  .cm-s-default .CodeMirror-matchingbracket {
    text-decoration: underline;
    color: white !important;
  }
}
</style>
{% endblock %}

A  => meta-custom/base-templates/internal_error.html +7 -0
@@ 1,7 @@
{% extends "layout.html" %}
{% block body %}
<div class="container">
  <h2>500 服务器内部错误</h2>
  <p>出了点问题,请稍后再试。</p>
</div>
{% endblock %}

A  => meta-custom/base-templates/layout-full.html +6 -0
@@ 1,6 @@
{% extends "layout.html" %}
{% block nav %}
<nav class="navbar navbar-light navbar-expand-sm">
  {% include 'nav.html' %}
</nav>
{% endblock %}

A  => meta-custom/base-templates/layout.html +55 -0
@@ 1,55 @@
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {% block title %}
    <title>{{domain}}</title>
    {% endblock %}
    {% block favicon %}
    <link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
    <link rel="icon" type="image/png" href="/static/logo.png" sizes="any" />
    {% endblock favicon %}
    {% if app.debug %}
    <link rel="stylesheet" href="/static/main.css">
    {% else %}
    <link rel="stylesheet" href="/{{static_resource("static/main.min.css")}}">
    {% endif %}
    {% if page and page != 1 %}
    <link rel="prev" href="?page={{ page - 1 }}{{ coalesce_search_terms() }}" />
    {% endif %}
    {% if page and page != total_pages %}
    <link rel="next" href="?page={{ page + 1 }}{{ coalesce_search_terms() }}" />
    {% endif %}
    {% block head %}
    {% endblock %}
  </head>
  <body>
    {% block environment %}
    {% if environment != "production" or
      (current_user and current_user.user_type.value == 'admin' )%}
    <div style="
        {% if environment == "production" %}
        background: #cc0022;
        {% else %}
        background: #228800;
        {% endif %}
        color: white; font-weight: bold; width: 100%; text-align: center">
      {{environment.upper()}} ENVIRONMENT
    </div>
    {% endif %}
    {% endblock %}
    {% block nav %}
    <nav class="container navbar navbar-light navbar-expand-sm">
      {% include 'nav.html' %}
    </nav>
    {% endblock %}
    {% block body %}
    <div class="container">
      {% block content %}{% endblock %}
    </div>
    {% endblock %}
    {% block modal %}{% endblock %}
    {% block scripts %}{% endblock %}
  </body>
</html>

A  => meta-custom/base-templates/nav.html +60 -0
@@ 1,60 @@
{% if "hub.sr.ht" in network %}
<span class="navbar-brand">
  {{icon('circle')}}
  <a href="{{get_origin("hub.sr.ht", external=True)}}">
    {{site_name}}
  </a>
</span>
{% else %}
<span class="navbar-brand">
  {{icon('circle')}}
  <a class="navbar-brand" href="/">
    {{site_name}}
    <span class="text-danger">{{site.split(".")[0]}}</span>
  </a>
</span>
{% endif %}
<ul class="navbar-nav">
  {% if current_user %}
  {% for _site in network %}

  {% if _site != "hub.sr.ht" %}
  <li class="nav-item {{'active' if _site == site else ''}}">
    <a
      class="nav-link"
      href="{{get_origin(_site, external=True)}}"
    >{{_site.split(".")[0]}}</a>
  </li>
  {% endif %}

  {% endfor %}
  {% endif %}
</ul>
<div class="login">
  {% if current_user %}
  <span class="navbar-text">
    已登录:
    {% set hubsrht = get_origin("hub.sr.ht", external=True, default=None) %}
    {% if hubsrht %}
    <a href="{{hubsrht}}/~{{current_user.username}}">
    {% else %}
    <a href="{{get_origin("meta.sr.ht", external=True)}}/profile">
    {% endif %}
      {{current_user.username}}</a>
    &mdash;
    <a href="{{logout_url}}">登出</a>
  </span>
  {% else %}
  <span class="navbar-text">
    {% if site == 'meta.sr.ht' %}
    <a href="/login">登录</a>
    &mdash;
    <a href="/">注册</a>
    {% else %}
    <a href="{{ oauth_url }}" rel="nofollow">登录</a>
    &mdash;
    <a href="{{get_origin("meta.sr.ht", external=True)}}">注册</a>
    {% endif %}
  </span>
  {% endif %}
</div>

A  => meta-custom/base-templates/not_found.html +10 -0
@@ 1,10 @@
{% extends "layout.html" %}
{% block body %}
<div class="container">
  <h2>404 页面未找到</h2>
  <p>
  你要找的页面不存在。
  <a href="/">返回首页 <i class="fa fa-caret-right"></i></a>
  </p>
</div>
{% endblock %}

A  => meta-custom/base-templates/oauth-error.html +7 -0
@@ 1,7 @@
{% extends "layout.html" %}
{% block content %}
<div class="container">
  <h2>Error logging in</h2>
  <p>{{ details }}</p>
</div>
{% endblock %}

A  => meta-custom/base-templates/pagination.html +29 -0
@@ 1,29 @@
{% if total_pages > 1 %}
<div class="row">
  <div class="col-4">
  {% if page != 1 %}
    <a
      class="btn btn-default"
      href="?page={{ page - 1 }}{{ coalesce_search_terms() }}"
    >
      {{icon('caret-left')}}
      prev
    </a>
  {% endif %}
  </div>
  <div class="col-4 text-centered">
    {{ page }} / {{ total_pages }}
  </div>
  <div class="col-4 text-right">
  {% if page != total_pages %}
    <a
      class="btn btn-default"
      href="?page={{ page + 1 }}{{ coalesce_search_terms() }}"
    >
      next
      {{icon('caret-right')}}
    </a>
  {% endif %}
  </div>
</div>
{% endif %}

A  => meta-custom/base-templates/read_only.html +28 -0
@@ 1,28 @@
{% extends "layout.html" %}
{% block nav %}{% endblock %}
{% block environment %}{% endblock %}
{% block body %}
<style>
#warning {
  background: repeating-linear-gradient(
    45deg,
    yellow,
    yellow 10px,
    #856404 10px,
    #856404 20px
  );
  height: 1rem;
  margin-bottom: 1rem;
}

h2 { text-align: center; }
</style>
<div id="warning"></div>
<div class="container">
  <h2>{{cfg('sr.ht', 'site-name')}} 当前处于只读模式</h2>
  <p>
    系统正在进行维护,暂时无法处理你的请求。这通常发生在计划维护期间或意外故障时。
    请刷新页面重试,或点击浏览器的返回按钮回到上一页。
  </p>
</div>
{% endblock %}

A  => meta-custom/base-templates/suspended.html +9 -0
@@ 1,9 @@
{% extends "layout.html" %}
{% block content %}
<h2>你的账户已被停用</h2>
<p>原因如下:</p>
<blockquote>{{ notice }}</blockquote>
<p>
  请<a href="mailto:{{cfg('sr.ht', 'owner-email')}}">联系管理员</a>。
</p>
{% endblock %}

A  => meta-custom/base-templates/unauthorized.html +10 -0
@@ 1,10 @@
{% extends "layout.html" %}
{% block body %}
<div class="container">
  <h2>401 未授权</h2>
  <p>
  你没有访问此页面的权限。
  <a href="/">返回首页 <i class="fa fa-caret-right"></i></a>
  </p>
</div>
{% endblock %}

A  => meta-custom/fix_jinja2_do.py +33 -0
@@ 1,33 @@
"""Enable Jinja2 'do' extension in sourcehut Flask app.

Upstream fix: https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/39036
"""
import glob

candidates = glob.glob("/usr/lib/python3.*/site-packages/srht/flask.py")
if not candidates:
    raise FileNotFoundError("Cannot find srht/flask.py")

source_file = candidates[0]

with open(source_file) as f:
    content = f.read()

target = "self.jinja_loader = ChoiceLoader"
patch = '        self.jinja_env.add_extension("jinja2.ext.do")'

if "jinja2.ext.do" not in content:
    lines = content.split("\n")
    patched = False
    for i, line in enumerate(lines):
        if target in line:
            lines.insert(i + 1, patch)
            patched = True
            break
    if not patched:
        raise RuntimeError(f"Could not find '{target}' in {source_file}")
    with open(source_file, "w") as f:
        f.write("\n".join(lines))
    print(f"Patched {source_file}: enabled jinja2.ext.do")
else:
    print(f"Already patched: {source_file}")

A  => meta-custom/templates/already-confirmed.html +19 -0
@@ 1,19 @@
{% extends "layout.html" %}
{% block title %}
<title>确认账户 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8">
    <h3>
      账户已确认
    </h3>
    <p>
      你的 {{cfg("sr.ht", "site-name")}} 账户已经确认过了。
    </p>
    <a href="{{redir}}" class="btn btn-primary">
      继续 {{icon('caret-right')}}
    </a>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/are-you-sure.html +19 -0
@@ 1,19 @@
{% extends "meta.html" %}
{% block content %}
<div class="row">
  <section class="col-md-12">
    <h3>确认操作</h3>
    <p>你确定要 {{ blurb | safe }} 吗?此操作无法撤销。</p>
    <form method="POST" action="{{ action }}">
      {{csrf_token()}}
      <button type="submit" class="btn btn-danger">
        确认 {{icon('caret-right')}}
      </button>
      <a
        href="{{ cancel }}"
        class="btn btn-default"
      >取消 {{icon('caret-right')}}</a>
    </form>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/audit-log.html +31 -0
@@ 1,31 @@
{% extends "meta.html" %}
{% block title %}
<title>审计日志 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-12">
    <h3>审计日志</h3>
    <table class="table">
      <thead>
        <tr>
          <th>IP 地址</th>
          <th>操作</th>
          <th>详情</th>
          <th>时间</th>
        </tr>
      </thead>
      <tbody>
        {% for log in audit_log %}
        <tr>
          <td>{{log.ip_address}}</td>
          <td>{{log.event_type}}</td>
          <td>{{log.details or ""}}</td>
          <td>{{log.created | date }}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/billing-change-period.html +62 -0
@@ 1,62 @@
{% extends "layout.html" %}
{% block title %}
<title>Update billing period - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-4">
    <p>
      Thank you for supporting {{cfg("sr.ht", "site-name")}}!
      {% if current_user.user_type != UserType.active_paying %}
      You will be charged when you click "submit payment", and your plan will
      be automatically renewed at the end of the term.
      {% endif %}
    </p>
  </div>
  <div class="col-md-8">
    <h3>Confirm subscription details</h3>
    <form method="POST" id="payment-form">
      {{csrf_token()}}
      <fieldset style="margin-bottom: 1rem">
        <legend style="font-weight: bold">Payment term</legend>
        <div class="form-check form-check-inline">
          <input
            class="form-check-input"
            type="radio"
            name="term"
            id="term-monthly"
            value="monthly"
            checked>
          <label class="form-check-label" for="term-monthly">
            ${{"{:.2f}".format(current_user.payment_cents / 100)}} per month
          </label>
        </div>
        <div class="form-check form-check-inline">
          <input
            class="form-check-input"
            type="radio"
            name="term"
            id="term-yearly"
            value="yearly">
          <label class="form-check-label" for="term-yearly">
            ${{"{:.2f}".format(current_user.payment_cents / 100 * 10)}} per year
          </label>
        </div>
      </fieldset>
      <div class="form-group">
        <button class="btn btn-primary" type="submit">
          Submit changes
          {{icon('caret-right')}}
        </button>
      </div>
      {% if current_user.user_type == UserType.active_paying %}
      <div class="alert alert-info">
        Your account is paid for and up-to-date. These changes will take effect
        at the start of your next billing period
        ({{current_user.payment_due | date}}).
      </div>
      {% endif %}
    </form>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/billing-complete.html +14 -0
@@ 1,14 @@
{% extends "layout.html" %}
{% block title %}
<title>Billing complete - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-6">
    <p>Your payment has been processed successfully. Thank you!</p>
    <a href="{{onboarding_redirect}}" class="btn btn-primary">
      Continue to docs &amp; tutorials {{icon('caret-right')}}
    </a>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/billing-initial.html +167 -0
@@ 1,167 @@
{% extends "layout.html" %}
{% block title %}
<title>Choose a plan - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>
      Choose a plan
    </h3>
  </div>
</div>
<div class="row">
  <div class="col-md-12">
    <p>
      On {{cfg("sr.ht", "site-name")}}, all plans have access to the same
      features and in the same quantity. You should pick the plan which best
      matches your financial needs and best represents the level of investment
      you have in {{cfg("sr.ht", "site-name")}}. If you require financial aid
      to use {{cfg("sr.ht", "site-name")}}, please
      <a href="mailto:{{cfg("sr.ht", "owner-email")}}">send us an email</a>
      explaining your circumstances and we'll do our best to accommodate your
      needs.
    </p>
    <div class="alert alert-danger">
      <strong>Notice</strong>: {{cfg("sr.ht", "site-name")}} is currently
      considered at an alpha stage of development, and the quality of the
      service may reflect that. However, the service is reliable, stable,
      secure, and mostly complete at this stage of development. To learn
      exactly what the alpha entails,
      <a
        href="https://sourcehut.org/alpha-details/"
        rel="noopener"
        target="_blank"
      >consult this document</a>.
      During the alpha, payment is encouraged, but optional, for most features.
      <a
        href="{{url_for("profile.profile_GET")}}"
      >Continue without payment {{icon('caret-right')}}</a>.
    </div>
  </div>
</div>
<div class="row event-list" style="text-align: center">
  <div class="col-md-4">
    <form class="event" method="POST">
      {{csrf_token()}}
      <h3>Amateur Hackers</h3>
      <p>Includes access to all features.</p>
      <div style="margin-bottom: 1rem">
        <big>$2/month</big> or <big>$20/year</big>
      </div>
      <input type="hidden" name="amount" value="200" />
      <input type="hidden" name="plan" value="Amateur Hacker" />
      <button type="submit" class="btn btn-block btn-primary">
        Continue {{icon("caret-right")}}
      </button>
    </form>
  </div>
  <div class="col-md-4">
    <form class="event" method="POST">
      {{csrf_token()}}
      <h3>Typical Hackers</h3>
      <p>Includes access to all features.</p>
      <div class="text-center" style="margin-bottom: 1rem">
        <big>$5/month</big> or <big>$50/year</big>
      </div>
      <input type="hidden" name="amount" value="500" />
      <input type="hidden" name="plan" value="Typical Hacker" />
      <button type="submit" class="btn btn-block btn-primary">
        Continue {{icon("caret-right")}}
      </button>
    </form>
  </div>
  <div class="col-md-4">
    <form class="event" method="POST">
      {{csrf_token()}}
      <h3>Professional Hackers</h3>
      <p>Includes access to all features.</p>
      <div class="text-center" style="margin-bottom: 1rem">
        <big>$10/month</big> or <big>$100/year</big>
      </div>
      <input type="hidden" name="amount" value="1000" />
      <input type="hidden" name="plan" value="Professional Hacker" />
      <button type="submit" class="btn btn-block btn-primary">
        Continue {{icon("caret-right")}}
      </button>
    </form>
  </div>
</div>
<div class="row">
  <div class="col-md-12">
    <p class="text-muted text-center">
      <small>All prices are shown in US dollars.</small>
    </p>
    <div class="alert alert-warning">
      <strong>Notice</strong>: Continuing to the next page will execute
      non-free JavaScript from our payment processor,
      <a
        href="https://stripe.com/"
        target="_blank"
        rel="nofollow noopener"
      >Stripe</a>. If you are uncomfortable with this, please
      <a href="mailto:{{cfg('sr.ht', 'owner-email')}}">reach out to us</a> to
      discuss other options. For more information, consult our
      <a
        href="https://man.sr.ht/privacy.md"
        target="_blank"
        rel="noopener"
      >privacy policy</a>.
    </div>
    <p>
      You can cancel or change your plan at any time. Any questions?  Review our
      <a href="https://man.sr.ht/billing-faq.md">billing &amp; payments FAQ</a>.
    </p>
  </div>
</div>
<div class="row" style="margin-top: 3rem">
  <div class="col-md-12">
    <h3>Other ways to use {{cfg("sr.ht", "site-name")}}</h3>
  </div>
</div>
<div class="row event-list">
  <div class="col-md-4">
    <div class="event">
      <h3>Contributors</h3>
      <p>
        Just here to contribute bug reports, patches, and so on, to existing
        projects? You don't need to have a paid account for that.
      </p>
      <a
        class="btn btn-info btn-block"
        href="{{cfg("meta.sr.ht::settings", "onboarding-redirect")}}"
      >Continue {{icon("caret-right")}}</a>
    </div>
  </div>
  <div class="col-md-4">
    <div class="event">
      <h3>Earn free service</h3>
      <p>
        {{cfg("sr.ht", "site-name")}} is itself an
        <a
          href="https://sr.ht/~sircmpwn/sourcehut"
        >open-source project</a>. Users who contribute patches to the upstream
        project are eligible for free service.
      </p>
      <a
        class="btn btn-info btn-block"
        href="https://man.sr.ht/billing-faq.md#i-donx27t-think-i-can-pay-for-it"
        target="_blank"
      >Learn more {{icon("caret-right")}}</a>
    </div>
  </div>
  <div class="col-md-4">
    <div class="event">
      <h3>Install it yourself</h3>
      <p>
        {{cfg("sr.ht", "site-name")}} is 100% free and open source software, and
        you can run it on your own servers free of charge if you prefer to host
        it yourself.
      </p>
      <a class="btn btn-info btn-block" href="https://man.sr.ht/installation.md">
        Read the docs {{icon("caret-right")}}
      </a>
    </div>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/billing-invoice.html +45 -0
@@ 1,45 @@
{% extends "meta.html" %}
{% block title %}
<title>Download invoice - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-6">
    <h3>Download Invoice</h3>
    <div class="event-list">
      <div class="event">
        <h4>
          <small class="text-success" style="margin-left: 0">
            {{icon('check')}}
          </small>
          ${{"{:.2f}".format(invoice.cents/100)}}
          <small>
            with {{invoice.source}}
          </small>
        </h4>
        <p>
          Paid {{invoice.created | date}}<br />
          Valid for service until {{invoice.valid_thru | date}}
        </p>
      </div>
    </div>
    <form method="POST">
      {{csrf_token()}}
      <div class="form-group">
        <label for="address-to">Address invoice to (optional):</label>
        <textarea
          class="form-control"
          name="address-to"
          id="address-to"
          rows="5"
          placeholder="Billing address, VAT number, etc"
        ></textarea>
      </div>
      <button type="submit" class="btn btn-primary">
        Download invoice
        {{icon('caret-right')}}
      </button>
    </form>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/billing.html +250 -0
@@ 1,250 @@
{% extends "meta.html" %}
{% block title %}
<title>Billing - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>Billing Information</h3>
    {% if message %}
    <div class="alert alert-info">{{message}}</div>
    {% endif %}

    {% if current_user.user_type == UserType.active_non_paying %}

    <div class="alert alert-info">
      You are currently using an <strong>unpaid</strong>
      {{cfg("sr.ht", "site-name")}} account. Some site features may be
      unavailable to your account.
    </div>

    {% elif current_user.user_type == UserType.active_free %}

    <div class="alert alert-info">
      Your account is <strong>exempt</strong> from billing. All features are
      available to you free of charge. You may choose to set up billing
      if you wish to support the site.
    </div>

    {% elif current_user.user_type == UserType.active_paying %}

    {% if current_user.payment_cents == 0 %}

    <div class="alert alert-warning">
      Your paid service has been cancelled. At the end of your current term,
      {{current_user.payment_due | date}}, your account will be downgraded to a
      non-paying account.
    </div>

    {% else %}

    <div class="alert alert-success">
      {% if invoices %}
      Your account is <strong>paid</strong> and up-to-date, and your last
      payment of {{"${:.2f}".format(invoices[0].cents/100)}}
      was made {{invoices[0].created | date}}. Your current
      {{current_user.payment_interval.value}} payment is
      {{"${:.2f}".format(current_user.payment_cents/100
        if current_user.payment_interval.value == "monthly"
        else current_user.payment_cents*10/100)}} and will be billed
      {{invoices[0].valid_thru | date}}.
      {% else %}
      Your account is <strong>paid</strong> and up-to-date. Your
      current {{current_user.payment_interval.value}} payment is
      {{"${:.2f}".format(current_user.payment_cents/100
        if current_user.payment_interval.value == "monthly"
        else current_user.payment_cents*10/100)}}.
      {% endif %}
    </div>

    {% endif %}

    {% elif current_user.user_type == UserType.active_delinquent %}

    <div class="alert alert-danger">
      <strong>Notice</strong>: Your payment is past due. Please check that your
      payment information is correct or your service may be impacted.
    </div>

    {% elif current_user.user_type == UserType.admin %}

    <div class="alert alert-info">
      Admins are exempt from billing.
    </div>

    {% endif %}

    <div style="margin-bottom: 1rem">
      <div class="progress">
        <div class="progress-bar"
          role="progressbar"
          style="width: {{paid_pct}}%;"
          aria-valuenow="{{paid_pct}}"
          aria-valuemin="0" aria-valuemax="100"
        >{{paid_pct}}% paid</div>
        <div
          class="goal"
          style="left: 13.37%"
          title="Presented without comment"
        >13.37%</div>
        <div class="progress-bar total">of {{total_users}} registered users</div>
      </div>
      <small class="text-muted pull-right">
        Current number of paid accounts on {{cfg("sr.ht", "site-name")}}
      </small>
    </div>
  </div>
</div>
{% if current_user.user_type == UserType.active_paying
  and current_user.payment_cents != 0 %}
<div class="row" style="margin-bottom: 1rem">
  <div class="col-md-3 offset-md-6">
    <a
      href="{{url_for("billing.billing_initial_GET")}}"
      class="btn btn-default btn-block"
    >Change your plan {{icon('caret-right')}}</a>
  </div>
  <div class="col-md-3">
    <form method="POST" action="{{url_for("billing.cancel_POST")}}">
      {{csrf_token()}}
      <button class="btn btn-danger btn-block">
        Cancel your plan {{icon('caret-right')}}
      </button>
    </form>
  </div>
</div>
{% elif current_user.user_type == UserType.active_delinquent %}
<div class="row" style="margin-bottom: 1rem">
  <div class="col-md-3 offset-md-3">
    <a
      href="{{url_for("billing.billing_initial_GET")}}"
      class="btn btn-default btn-block"
    >Change your plan {{icon('caret-right')}}</a>
  </div>
  <div class="col-md-3">
    <form method="POST" action="{{url_for("billing.cancel_POST")}}">
      {{csrf_token()}}
      <button class="btn btn-danger btn-block">
        Cancel your plan {{icon('caret-right')}}
      </button>
    </form>
  </div>
  <div class="col-md-3">
    <a
      href="{{url_for("billing.new_payment_GET")}}"
      class="btn btn-primary btn-block"
    >Add payment method {{icon('caret-right')}}</a>
  </div>
</div>
{% elif current_user.user_type == UserType.active_paying
  and current_user.payment_cents == 0 %}
<div class="row" style="margin-bottom: 1rem">
  <div class="col-md-3 offset-md-9">
    <a
      href="{{url_for("billing.billing_initial_GET")}}"
      class="btn btn-primary btn-block"
    >Renew your account {{icon('caret-right')}}</a>
  </div>
</div>
{% elif current_user.user_type in [
  UserType.active_non_paying,
  UserType.active_free
] %}
<div class="row" style="margin-bottom: 1rem">
  <div class="col-md-3 offset-md-9">
    <a
      href="{{url_for("billing.billing_initial_GET")}}"
      class="btn btn-primary btn-block"
    >Set up billing {{icon('caret-right')}}</a>
  </div>
</div>
{% endif %}
<div class="row">
  <div class="col-md-6">
    {% if current_user.user_type in [
      UserType.active_paying,
      UserType.active_delinquent
    ] %}
    <h3>Payment methods</h3>
    <div class="event-list">
      {% for source in customer.sources %}
      <div class="event row">
        <div class="col-md-8">
          {{source.brand}} ending in {{source.last4}}
          <br />
          <span class="text-muted">
            {% if source.address_zip %}
            Post code {{source.address_zip}}.
            {% endif %}
            Expires {{source.exp_month}}/{{source.exp_year}}.
          </span>
        </div>
        <div class="col-md-4">
          <div style="text-align: right">
            {% if source.stripe_id == customer.default_source %}
            {{icon('check', cls="text-success")}} Default
            {% else %}
            <form style="margin: 0;" method="POST"
              action="{{url_for("billing.payment_source_remove",
                source_id=source.id)}}">
              {{csrf_token()}}
              <button class="btn btn-link">
                Remove {{icon('times')}}
              </button>
            </form>
            <form method="POST"
              action="{{url_for("billing.payment_source_make_default",
                source_id=source.id)}}">
              {{csrf_token()}}
              <button class="btn btn-link">
                Make default {{icon('caret-right')}}
              </button>
            </form>
            {% endif %}
          </div>
        </div>
      </div>
      {% endfor %}
    </div>
    <a
      href="{{url_for("billing.new_payment_GET")}}"
      class="btn btn-primary pull-right"
    >New payment method {{icon('caret-right')}}</a>
    {% endif %}
  </div>
  {% if len(invoices) != 0 %}
  <div class="col-md-6">
    <h3>Invoices</h3>
    <div class="event-list">
      {% for invoice in invoices %}
      <div class="event invoice">
        <h4>
          <small class="text-success" style="margin-left: 0">
            {{icon('check')}}
          </small>
          ${{"{:.2f}".format(invoice.cents/100)}}
          <small>
            with {{invoice.source}}
          </small>
          <small>
            <a
              class="pull-right"
              href="{{url_for("billing.invoice_GET", invoice_id=invoice.id)}}"
            >Export as PDF</a>
          </small>
        </h4>
        <p>
          Paid {{invoice.created | date}}<br />
          Valid for service until {{invoice.valid_thru | date}}
          {% if invoice.comment %}
          <br />
          {{invoice.comment}}
          {% endif %}
        </p>
      </div>
      {% endfor %}
    </div>
  </div>
  {% endif %}
</div>
{% endblock %}

A  => meta-custom/templates/client-admin.html +10 -0
@@ 1,10 @@
{% extends "meta.html" %}
{% block blurb %}
<h2>OAuth client settings</h2>
<section>
  <p>You can manage settings for <strong>{{ client.client_name }}</strong> here.</p>
</section>
{% endblock %}
{% block tabs %}
{% include "client-tabs.html" %}
{% endblock %}

A  => meta-custom/templates/client-delete.html +15 -0
@@ 1,15 @@
{% extends "client-admin.html" %}
{% block content %}
<p>
  This will permenantely delete your OAuth client,
  <strong>{{ client.client_name }}</strong>, and revoke all OAuth tokens you
  have been issued.
</p>
<form method="POST">
  {{csrf_token()}}
  <button type="submit" class="btn btn-danger">
    Proceed and delete {{icon("caret-right")}}
  </button>
  <a href="/oauth" class="btn btn-default">Nevermind</a>
</form>
{% endblock %}

A  => meta-custom/templates/client-security.html +17 -0
@@ 1,17 @@
{% extends "client-admin.html" %}
{% block content %}
<h3>Reset client secret</h3>
<p>If your client secret is compromised, regenerate it here.</p>
<form action="/oauth/reset-secret/{{ client.client_id }}" method="POST">
  {{csrf_token()}}
  <button class="btn btn-danger">
    Reset client secret {{icon("caret-right")}}
  </button>
</form>
<h3>Revoke all tokens</h3>
<p>You can revoke all issued OAuth tokens at once here.</p>
<a class="btn btn-danger" href="/oauth/revoke-tokens/{{client.client_id}}">
  Revoke {{ len(client.tokens) }} token{% if len(client.tokens) > 1 %}s{% endif %}
  {{icon("caret-right")}}
</a>
{% endblock %}

A  => meta-custom/templates/client-settings.html +33 -0
@@ 1,33 @@
{% extends "client-admin.html" %}
{% block content %}
<div class="row">
  <section class="col-md-6">
    <form method="POST" action="/oauth/register">
      {{csrf_token()}}
      <div class="form-group">
        <label for="client-name">Client Name <span class="text-danger">*</span></label>
        <input
          type="text"
          name="client-name"
          id="client-name"
          class="form-control {{valid.cls("client-name")}}"
          value="{{ client.client_name or "" }}" />
        {{valid.summary("client-name")}}
      </div>
      <div class="form-group">
        <label for="redirect-uri">Base Redirect URI <span class="text-danger">*</span></label>
        <input
          type="text"
          name="redirect-uri"
          id="redirect-uri"
          class="form-control {{valid.cls("redirect-uri")}}"
          value="{{ client.redirect_uri or "" }}" />
        {{valid.summary("redirect-uri")}}
      </div>
      <button type="submit" class="btn btn-primary">
        Submit {{icon("caret-right")}}
      </button>
    </form>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/client-tabs.html +20 -0
@@ 1,20 @@
{% macro link(path, title) %}
<a
  class="nav-link {% if request.path.endswith(path) %}active{% endif %}"
  href="/oauth/client/{{ client.client_id }}{{ path }}">{{ title }}</a>
{% endmacro %}

<li class="nav-item">
  <a class="nav-link" href="/oauth">
    {{icon("caret-left")}} back
  </a>
</li>
<li class="nav-item">
  {{ link("/settings", "settings") }}
</li>
<li class="nav-item">
  {{ link("/security", "security") }}
</li>
<li class="nav-item">
  {{ link("/delete", "delete") }}
</li>

A  => meta-custom/templates/forgot.html +39 -0
@@ 1,39 @@
{% extends "layout.html" %}
{% block title %}
<title>重置密码 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8 offset-md-2">
    <h3>
      重置密码
    </h3>
    {% if done %}
    <p>
      密码重置链接已发送到你的邮箱。
    </p>
    {% elif allow_password_reset() %}
    <form method="POST" action="/forgot">
      {{csrf_token()}}
      <div class="form-group">
        <label for="username">邮箱地址</label>
        <input
           type="text"
           name="email"
           id="email"
           class="form-control {{valid.cls("email")}}"
           value="{{email or ""}}" />
        {{valid.summary("email")}}
      </div>
      {{valid.summary()}}
      <button class="btn btn-primary pull-right" type="submit">
        继续 {{icon('caret-right')}}
      </button>
    </form>
    {% else %}
    <p>无法重置密码,因为认证由其他服务管理。
    请联系系统管理员了解如何重置密码。</p>
    {% endif %}
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/index.html +27 -0
@@ 1,27 @@
{% extends "layout.html" %}
{% block body %}
<div class="container">
  <div class="row">
    <div class="col-md-8">
      <p>
        欢迎来到 <strong>{{domain}}</strong>!这是
        <a href="{{cfg("sr.ht", "site-info")}}">
          {{cfg("sr.ht", "site-name")}}</a>
        的账户与安全管理中心。功能包括:
      </p>
      <ul>
        <li>PGP 加密和签名的服务通知邮件</li>
        <li>基于 TOTP 的两步验证</li>
        <li>详细的账户活动审计日志</li>
        <li>细粒度的第三方 OAuth 访问控制</li>
      </ul>
      <a class="btn btn-primary" href="/register">
        注册 {{icon('caret-right')}}
      </a>
      <a href="/login">
        登录 {{icon('caret-right')}}</a>
    </div>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/keys.html +144 -0
@@ 1,144 @@
{% extends "meta.html" %}
{% block title %}
<title>密钥管理 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12 event-list">
    <section class="event">
      <h3>SSH 密钥</h3>
      {% if any(current_user.ssh_keys) %}
      <p>以下 SSH 密钥已关联到你的账户:</p>
      <table class="table">
        <thead>
          <tr>
            <th>名称</th>
            <th>指纹</th>
            <th>添加时间</th>
            <th>最后使用</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for key in current_user.ssh_keys %}
          <tr>
            <td>{{key.comment}}</td>
            <td>{{key.fingerprint}}</td>
            <td>{{key.created|date}}</td>
            <td>{{key.last_used|date}}</td>
            <td style="width: 6rem">
              <form method="POST" action="/keys/delete-ssh/{{key.id}}">
                {{csrf_token()}}
                <button type="submit" class="btn btn-danger btn-fill">
                  删除
                </button>
              </form>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% endif %}
      <form method="POST" action="/keys/ssh-keys">
        {{csrf_token()}}
        <div class="form-group">
          <label for="ssh-key">SSH 公钥:</label>
          <input
            type="text"
            class="form-control {{valid.cls("ssh-key")}}"
            id="ssh-key"
            name="ssh-key"
            aria-describedBy="sshkey-details"
            value="{{ssh_key or ""}}"
            placeholder="ssh-ed25519 bWFyYmxlY2FrZQo= ..." />
          <small id="sshkey-details" class="form-text text-muted">
            你的 SSH 公钥列表可通过
            <a
              href="{{url_for("profile.user_keys_GET",
                username=current_user.username)}}"
            >{{current_user.canonical_name}}.keys</a> 公开访问
          </small>
          {{valid.summary("ssh-key")}}
        </div>
        {{valid.summary()}}
        <button type="submit" class="btn btn-primary pull-right">
          添加密钥 {{icon("caret-right")}}
        </button>
      </form>
    </section>
    <section class="event">
      <h3>PGP 密钥</h3>
      {% if any(current_user.pgp_keys) %}
      <p>以下 PGP 密钥已关联到你的账户:</p>
      <table class="table">
        <thead>
          <tr>
            <th>指纹</th>
            <th>添加时间</th>
            <th>过期时间</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for key in current_user.pgp_keys %}
          <tr>
            <td>{{key.fingerprint_hex}}</td>
            <td>{{key.created|date}}</td>
            {% if not key.expiration %}
            <td>永不过期</td>
            {% elif key.expiration > now %}
            <td>{{key.expiration|date}}</td>
            {% else %}
            <td><span class="text-danger">已过期 {{key.expiration|date}}</span></td>
            {% endif %}
            <td style="width: 6rem">
              <form method="POST" action="/keys/delete-pgp/{{key.id}}">
                {{csrf_token()}}
                <button type="submit" class="btn btn-danger btn-fill">删除</button>
              </form>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% endif %}
      {% if tried_to_delete_key_in_use %}
      <div class="alert alert-danger">此密钥当前用于邮件加密。请先在
          <a href="/privacy">隐私设置</a>中选择其他密钥或禁用邮件加密,
          然后再删除此密钥。</div>
      {% endif %}
      <form method="POST" action="/keys/pgp-keys">
        {{csrf_token()}}
        <div class="form-group">
          <label for="pgp-key">PGP 公钥:</label>
          <textarea
            class="form-control {{valid.cls("pgp-key")}}"
            id="pgp-key"
            name="pgp-key"
            style="font-family: monospace"
            rows="5"
            placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----
[...]
-----END PGP PUBLIC KEY BLOCK-----"
          >{{pgp_key or ""}}</textarea>
          <div id="gpg-command-section" class="form-text text-muted">
            你可以使用以下命令导出 PGP 密钥:<br>
            <code id="gpg-command">gpg --armor --export-options export-minimal --export {{email or current_user.email}}</code>
          </div>
          <small id="sshkey-details" class="form-text text-muted">
            你的 PGP 公钥列表可通过
            <a
              href="{{url_for("profile.user_pgp_keys_GET",
                username=current_user.username)}}"
            >{{current_user.canonical_name}}.pgp</a> 公开访问
          </small>
          {{valid.summary("pgp-key")}}
        </div>
        <button type="submit" class="btn btn-primary pull-right">
          添加密钥 {{icon("caret-right")}}
        </button>
      </form>
    </section>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/login.html +61 -0
@@ 1,61 @@
{% extends "layout.html" %}
{% block title %}
<title>登录 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-10 offset-md-1">
    <h3>
      登录 {{cfg("sr.ht", "site-name")}}
      <small>
        或 <a href="/register">注册</a>
      </small>
    </h3>
  </div>
</div>
<div class="row">
  <div class="col-md-6 offset-md-3">
    <form method="POST" action="/login">
      {{csrf_token()}}
      {% if login_context %}
      <div class="alert alert-info">
        {{login_context}}
      </div>
      {% endif %}
      <div class="form-group">
        <label for="username">用户名</label>
        <input
           type="text"
           name="username"
           id="username"
           class="form-control {{valid.cls("username")}}"
           value="{{username or ""}}"
           required
           autocomplete="username"
           {% if not username %} autofocus{% endif %} />
        {{valid.summary("username")}}
      </div>
      <div class="form-group">
        <label for="password">密码</label>
        <input
           type="password"
           name="password"
           id="password"
           class="form-control {{valid.cls("password")}}"
           required
           autocomplete="current-password"
           {% if username %} autofocus{% endif %} />
        {{valid.summary("password")}}
      </div>
      <input type="hidden" name="return_to" value="{{return_to or ""}}" />
      {{valid.summary()}}
      <button class="btn btn-primary pull-right" type="submit">
        登录 {{icon("caret-right")}}
      </button>
      <p>
        <a href="/forgot">忘记密码?</a>
      </p>
    </form>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/meta.html +34 -0
@@ 1,34 @@
{% extends "layout.html" %}
{% block head %}
<meta name="description" content="{{cfg("sr.ht", "site-name")}} 账户管理" />
{% endblock %}
{% block body %}
<div class="container">
  <div class="row">
    <div class="col-md-12">
      {% block blurb %}
      <section>
        <p>在此管理你的 {{cfg("sr.ht", "site-name")}} 账户。</p>
      </section>
      {% endblock %}
    </div>
  </div>
</div>
<div class="header-tabbed">
  <div class="container">
    <ul class="nav nav-tabs">
      {% block tabs %}
      {% include "tabs.html" %}
      {% endblock %}
    </ul>
  </div>
</div>
<div class="container">
  {% if notice %}
  <div class="alert alert-info">
    {{notice}}
  </div>
  {% endif %}
  {% block content %}{% endblock %}
</div>
{% endblock %}

A  => meta-custom/templates/new-payment.html +129 -0
@@ 1,129 @@
{% extends "layout.html" %}
{% block title %}
<title>Add payment method - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-4">
    <p>
      Thank you for supporting {{cfg("sr.ht", "site-name")}}!
      {% if current_user.user_type != UserType.active_paying %}
      You will be charged when you click "submit payment", and your plan will
      be automatically renewed at the end of the term.
      {% endif %}
      Your payment information is securely processed by
      <a href="https://stripe.com/" target="_blank">Stripe</a>.
    </p>
  </div>
  <div class="col-md-8">
    <h3>Payment information</h3>
    <noscript>
      <div class="alert alert-danger">
        <strong>JavaScript required</strong>.
        This is the only page which requires JavaScript. We require it here
        to protect your payment information by transmitting it directly to
        the payment processor. To proceed, please enable JavaScript and
        refresh the page. You can disable it again once your payment is
        complete and the rest of the site will work normally.
      </div>
    </noscript>
    <form method="POST" id="payment-form" style="display: none">
      {{csrf_token()}}
      <div class="form-group">
        <label for="card-element" style="font-weight: bold">
          Payment details
        </label>
        <div id="card-element" class="form-control"></div>
        <div id="card-error" class="invalid-feedback">
        </div>
      </div>
      {% if error %}
      <div class="alert alert-danger">
        {{error}}
      </div>
      {% endif %}
      <fieldset style="margin-bottom: 1rem">
        <legend style="font-weight: bold">Payment term</legend>
        <div class="form-check form-check-inline">
          <input
            class="form-check-input"
            type="radio"
            name="term"
            id="term-monthly"
            value="monthly"
            checked>
          <label class="form-check-label" for="term-monthly">
            ${{"{:.2f}".format(amount / 100)}} per month
          </label>
        </div>
        <div class="form-check form-check-inline">
          <input
            class="form-check-input"
            type="radio"
            name="term"
            id="term-yearly"
            value="yearly">
          <label class="form-check-label" for="term-yearly">
            ${{"{:.2f}".format(amount / 100 * 10)}} per year
          </label>
        </div>
      </fieldset>
      <input type="hidden" name="stripe-token" id="stripe-token" />
      <div class="form-group">
        {% if current_user.user_type == UserType.active_paying %}
        <button class="btn btn-primary" type="submit">
          Add payment method
          {{icon('caret-right')}}
        </button>
        {% else %}
        <button class="btn btn-primary" type="submit">
          Submit payment
          {{icon('caret-right')}}
        </button>
        {% endif %}
      </div>
      <p>
        Your payment is securely processed with
        <a href="https://stripe.com/">Stripe</a> over an encrypted connection.
        Your credit card details are never sent
        to {{cfg("sr.ht", "site-name")}} servers.
      </p>
    </form>
  </div>
</div>
<script src="https://js.stripe.com/v3/?advancedFraudSignals=false"></script>
<script>
document.getElementById('payment-form').style.display = 'block';
var stripe = Stripe('{{cfg("meta.sr.ht::billing", "stripe-public-key")}}');
var elements = stripe.elements();
var amount = {{amount}};
var card = elements.create('card');
card.mount('#card-element');
card.addEventListener('change', function(event) {
  var displayError = document.getElementById('card-error');
  var cardElement = document.getElementById('card-element');
  if (event.error) {
    displayError.textContent = event.error.message;
    cardElement.classList.add('is-invalid');
  } else {
    displayError.textContent = '';
    cardElement.classList.remove('is-invalid');
  }
});
var form = document.getElementById('payment-form');
form.addEventListener('submit', function(e) {
  e.preventDefault();
  stripe.createToken(card).then(function(result) {
    if (result.error) {
      var errorElement = document.getElementById('card-error');
      var cardElement = document.getElementById('card-element');
      errorElement.textContent = result.error.message;
      cardElement.classList.add('is-invalid');
    } else {
      document.getElementById('stripe-token').value = result.token.id;
      form.submit();
    }
  });
});
</script>
{% endblock %}

A  => meta-custom/templates/oauth-authorize.html +69 -0
@@ 1,69 @@
{% extends "layout.html" %}
{% block title %}
<title>Authorize account access - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<form class="row" method="POST" action="/oauth/authorize">
  {{csrf_token()}}
  <section class="col-md-6">
    <h3>Authorize account access</h3>
    <p>
      <strong>{{client.client_name}}</strong> would like access to your
      {{cfg("sr.ht", "site-name")}} account.
      <strong>{{client.client_name}}</strong> is a third-party application
      operated by <strong>{{client.user.username}}</strong>. You may revoke
      this access at any time. They would like permission to access
      the following resources on your account:
    </p>
    {% macro render_access(scope) %}
      {% if scope.access == 'read' %}
      {% if str(scope) == 'profile:read' %}
      <input type="checkbox" name="{{scope}}" checked disabled />
      {% else %}
      <input type="checkbox" name="{{scope}}" checked />
      {% endif %}
      <strong>read</strong>
      {% elif scope.access == 'write' %}
      <input type="checkbox" name="{{scope}}" checked />
      <strong>read</strong> and <strong>write</strong>
      {% endif %}
    {% endmacro %}
    <ul>
    {% for scope in scopes %}
    <li>
      {% if not scope.client_id %}
      {{render_access(scope)}} access to your
      <strong>{{scope.friendly()}}</strong>
      {% else %}
      {{render_access(scope)}} access to your
      <strong>{{scope.friendly()}}</strong> on your
      <strong>{{scope.client.client_name}}</strong> account
      {% endif %}
    </li>
    {% endfor %}
    </ul>
    <p>
      By unchecking the relevant permissions, you may change how much access
      <strong>{{client.client_name}}</strong> will have. However, note that
      this may cause undesirable behavior in the third-party application.
    </p>
    <input type="hidden" name="client_id" value="{{ client.client_id }}" />
    {% if redirect_uri %}
    <input type="hidden" name="redirect_uri" value="{{ redirect_uri }}" />
    {% endif %}
    {% if state %}
    <input type="hidden" name="state" value="{{ state }}" />
    {% endif %}
    <button
      type="submit"
      name="accept"
      class="btn btn-danger"
    >Proceed and grant access</button>
    <button
      type="submit"
      name="reject"
      class="btn btn-default"
    >Cancel and do not grant access</button>
  </section>
</form>
{% endblock %}

A  => meta-custom/templates/oauth-error.html +17 -0
@@ 1,17 @@
{% extends "layout.html" %}
{% block title %}
<title>Authorization error - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-6">
    <h3>An error occured</h3>
    <p>
      An error occurred processing a request to authorize third party access to
      your account. This is generally not a problem with
      {{cfg("sr.ht", "site-name")}}, but with the application.
    </p>
    <p class="text-muted">{{ details }}</p>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/oauth-oob.html +15 -0
@@ 1,15 @@
{% extends "layout.html" %}
{% block title %}
<title>OAuth Callback - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-6">
    <h3>Sign In</h3>
    <p>
      Please copy and paste the following into the application.
    </p>
    <pre>{{ exchange_token }}</pre>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/oauth-personal-token.html +53 -0
@@ 1,53 @@
{% extends "meta.html" %}
{% block title %}
<title>Personal access token - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <form class="col-md-12" method="POST" action="/oauth/personal-token">
    {{csrf_token()}}
    <h3>Personal Access Token</h3>
    {% if token %}
    <dl>
      <dt>Personal Access Token</dt>
      <dd>{{token}}</dt>
    </dl>
    <p>
      Your access token <strong>will never be shown to you again</strong>. Keep
      this secret.
    </p>
    <a href="/oauth" class="btn btn-primary">Continue {{icon('caret-right')}}</a>
    {% else %}
    <p>
      This will generate a valid OAuth token with complete access to your
      {{cfg("sr.ht", "site-name")}} account, all {{cfg("sr.ht", "site-name")}}
      services, and all third party accounts that use
      {{cfg("sr.ht", "site-name")}} for authentication.
      It will expire in one year, or when you manually revoke it.
    </p>
    <div class="form-group">
      <label for="comment">Comment</label>
      <input
        type="text"
        class="form-control {{valid.cls("comment")}}"
        id="comment"
        name="comment"
        value="{{comment or ""}}"
        aria-describedby="comment-help" />
      <small id="comment-help" class="text-muted">
        Arbitrary comment for personal reference only
      </small>
      {{valid.summary("comment")}}
    </div>
    <button
      type="submit"
      name="accept"
      class="btn btn-danger"
    >Proceed and generate token {{icon('caret-right')}}</button>
    <a
      class="btn btn-default" href="/oauth"
    >Nevermind {{icon('caret-right')}}</a>
    {% endif %}
  </form>
</div>
{% endblock %}

A  => meta-custom/templates/oauth-register.html +43 -0
@@ 1,43 @@
{% extends "meta.html" %}
{% block title %}
<title>Register OAuth client - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-6">
    <h3>Register OAuth client</h3>
    <p>
      Be sure to review the
      <a
        href="https://man.sr.ht/meta.sr.ht/api.md"
        target="_blank"
      >API docs</a>
      to understand how this works.
    </p>
    <form method="POST" action="/oauth/register">
      {{csrf_token()}}
      <div class="form-group">
        <label for="client-name">Client Name <span class="text-danger">*</span></label>
        <input
          type="text"
          name="client-name"
          id="client-name"
          class="form-control {{valid.cls("client-name")}}"
          value="{{ client_name or "" }}" />
        {{valid.summary("client-name")}}
      </div>
      <div class="form-group">
        <label for="redirect-uri">Base Redirect URI <span class="text-danger">*</span></label>
        <input
          type="text"
          name="redirect-uri"
          id="redirect-uri"
          class="form-control {{valid.cls("redirect-uri")}}"
          value="{{ redirect_uri or "" }}" />
        {{valid.summary("redirect-uri")}}
      </div>
      <button type="submit" class="btn btn-default">Register</button>
    </form>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/oauth-registered.html +28 -0
@@ 1,28 @@
{% extends "meta.html" %}
{% block title %}
<title>OAuth client registered - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-6">
    {% if client_event == "registered" %}
    <h3>OAuth client registered</h3>
    <p>Your OAuth client has been successfully registered. Write down this information:</p>
    {% elif client_event == "reset-secret" %}
    <h3>OAuth secret reset</h3>
    <p>Your OAuth client secret been successfully reset. Write down the new one:</p>
    {% endif %}
    <dl>
      <dt>Client ID</dt>
      <dd>{{client_id}}</dd>
      <dt>Client Secret</dt>
      <dd>{{client_secret}}</dt>
    </dl>
    <p>
      Your client secret <strong>will never be shown to you again</strong>, though you can
      reset it later if necessary. As the name implies, you should keep it secret.
    </p>
    <a href="/oauth" class="btn btn-default">Continue</a>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/oauth.html +130 -0
@@ 1,130 @@
{% extends "meta.html" %}
{% block title %}
<title>Authorized applications - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="alert alert-info">
  <strong>Heads up!</strong> This is the legacy OAuth dashboard. Credentials
  issued here are incompatible with OAuth 2.0 and the GraphQL APIs.
  <a href="{{url_for("oauth2.dashboard")}}" class="btn btn-link">
    Return to OAuth 2.0 Dashboard {{icon('caret-right')}}
  </a>
</div>
<div class="row">
  <div class="col-md-12 event-list">
    <section class="event">
      <h3>Personal Access Tokens</h3>
      {% if any(personal_tokens) %}
      <p>You have obtained the following personal access tokens:</p>
      <table class="table">
        <thead>
          <tr>
            <th>Access token</th>
            <th>Comment</th>
            <th>Date issued</th>
            <th>Last Used</th>
            <th>Expires</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for token in personal_tokens %}
          <tr>
            <td>{{ token.token_partial }}…</td>
            <td>{{ token.comment }}</td>
            <td>{{ token.created | date }}</td>
            <td>{{ token.updated | date }}</td>
            <td>{{ token.expires | date }}</td>
            <td style="width: 6rem">
              <a
                href="/oauth/revoke-token/{{ token.id }}"
                class="btn btn-danger btn-fill"
              >Revoke</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% else %}
      <p>You have not created any personal access tokens.</p>
      {% endif %}
      <a class="btn btn-primary" href="/oauth/personal-token">
        Generate new token {{icon("caret-right")}}
      </a>
    </section>
    <section class="event">
      <h3>Authorized Clients</h3>
      {% if any(client_authorizations) %}
      <p>The following third party clients have access to your account:</p>
      <table class="table">
        <thead>
          <tr>
            <th>Name</th>
            <th>Owner</th>
            <th>First Authorized</th>
            <th>Last Used</th>
            <th>Expires</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for token in client_authorizations %}
          <tr>
            <td>{{ token.client.client_name }}</td>
            <td>{{ token.client.user.username }}</td>
            <td>{{ token.created | date }}</td>
            <td>{{ token.updated | date }}</td>
            <td>{{ token.expires | date }}</td>
            <td style="width: 6rem">
              <a
                href="/oauth/revoke-token/{{ token.id }}"
                class="btn btn-danger btn-fill"
              >Revoke</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% else %}
      <p>You have not granted any third party clients access to your account.</p>
      {% endif %}
    </section>
    <section class="event">
      <h3>Registered Clients</h3>
      {% if any(current_user.oauth_clients) %}
      <p>You have registered the following OAuth clients:</p>
      <table class="table">
        <thead>
          <tr>
            <th>Name</th>
            <th>Client ID</th>
            <th>Active users</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for client in current_user.oauth_clients %}
          <tr>
            <td>{{ client.client_name }}</td>
            <td>{{ client.client_id }}</td>
            <td>{{ client_tokens(client) }}</td>
            <td style="width: 6rem">
              <a
                href="/oauth/client/{{client.client_id}}/settings"
                class="btn btn-default btn-fill"
              >Manage</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% else %}
      <p>You have not registered any OAuth clients yet.</p>
      {% endif %}
      <a class="btn btn-primary" href="/oauth/register">
        Register new client {{icon("caret-right")}}
      </a>
    </section>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-authorization.html +90 -0
@@ 1,90 @@
{% extends "layout.html" %}
{% block title %}
<title>Authorize account access - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>Authorize account access</h3>
  </div>
</div>
<form class="row" method="POST">
  {{csrf_token()}}
  <section class="col-md-8 offset-md-2 oauth2-authorize">
    <p>
      <strong><a
        href="{{client.url}}"
        target="_blank"
        rel="noopener nofollow"
      >{{client.name}} {{icon('external-link-alt')}}</a></strong>
      would like to access to your {{cfg("sr.ht", "site-name")}} account.
      <strong>{{client.name}}</strong> is a third-party application
      operated by <strong>{{client.owner.canonicalName}}</strong>.
      You may revoke this access at any time on the OAuth tab of your
      meta.sr.ht profile.
    </p>
    <h4>Review access request</h4>
    <p>{{client.name}} is requesting the following permissions:</p>
    {% macro render_access(grant) %}
      {% if grant[2] == 'RO' %}
      <input
        type="checkbox"
        class="{{valid.cls(grant[0] + "/" + grant[1] + ":RO")}}"
        name="{{grant[0]}}/{{grant[1]}}:RO"
        id="{{grant[0]}}/{{grant[1]}}:RO"
        checked />
      <label for="{{grant[0]}}/{{grant[1]}}:RO">read</label>
      {% elif grant[2] == 'RW' %}
      <input
        type="checkbox"
        class="{{valid.cls(grant[0] + "/" + grant[1] + ":RO")}}"
        name="{{grant[0]}}/{{grant[1]}}:RO"
        id="{{grant[0]}}/{{grant[1]}}:RO"
        {% if valid.cls(grant[0] + "/" + grant[1] + ":RO") != "is-invalid" %}
        checked
        {% endif %} />
      <label for="{{grant[0]}}/{{grant[1]}}:RO">read</label>
      and
      <input
        type="checkbox"
        class="{{valid.cls(grant[0] + "/" + grant[1] + ":RW")}}"
        name="{{grant[0]}}/{{grant[1]}}:RW"
        id="{{grant[0]}}/{{grant[1]}}:RW"
        checked />
      <label for="{{grant[0]}}/{{grant[1]}}:RW">write</label>
      {% endif %}
    {% endmacro %}
    <div class="event-list grant-list">
      <ul class="event">
        {% for grant in grants %}
        <li>
          {{render_access(grant)}} access to your
          <strong>{{grant[0]}} {{grant[1]}}</strong>
          {{valid.summary(grant[0] + "/" + grant[1] + ":RO")}}
        </li>
        {% endfor %}
      </ul>
    </div>
    <div class="alert alert-info">
      You may uncheck any permission to deny access, but doing so may prevent
      this third-party application from working correctly.
    </div>
    <input type="hidden" name="client_id" value="{{ client_id }}" />
    <input type="hidden" name="redirect_uri" value="{{ redirect_uri }}" />
    {% if state %}
    <input type="hidden" name="state" value="{{ state }}" />
    {% endif %}
    {{valid.summary()}}
    <button
      type="submit"
      name="accept"
      class="btn btn-danger"
    >Grant account access {{icon('caret-right')}}</button>
    <button
      type="submit"
      name="reject"
      class="btn btn-default"
    >Cancel {{icon('caret-right')}}</button>
  </section>
</form>
{% endblock %}

A  => meta-custom/templates/oauth2-client-registered.html +33 -0
@@ 1,33 @@
{% extends "meta.html" %}
{% block title %}
<title>OAuth 2.0 client registered - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    {% if not client_reissued %}
    <h3>OAuth 2.0 client registered</h3>
    {% else %}
    <h3>OAuth 2.0 client reissued</h3>
    <p>
      Your OAuth client has been issued new credentials. All previously issued
      bearer tokens have been revoked. You must send users back through the
      authorization process to restore access.
    </p>
    {% endif %}
    <dl>
      <dt>Client ID</dt>
      <dd><code>{{client_uuid}}</code></dt>
      <dt>Client secret</dt>
      <dd><code>{{client_secret}}</code></dt>
    </dl>
    <div class="alert alert-danger">
      Your client secret <strong>will never be shown to you again</strong>.
    </div>
    <a
      href="{{url_for('oauth2.dashboard')}}"
      class="btn btn-primary"
    >Continue {{icon('caret-right')}}</a>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-dashboard.html +156 -0
@@ 1,156 @@
{% extends "meta.html" %}
{% block title %}
<title>OAuth 2.0 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="alert alert-info">
  <strong>Notice!</strong> This is the OAuth 2.0 dashboard. Credentials issued
  here are incompatible with the legacy API!
  <a href="{{url_for("oauth_web.oauth_GET")}}" class="btn btn-link">
    Proceed to legacy OAuth Dashboard {{icon('caret-right')}}
  </a>
</div>
<div class="row">
  <div class="col-md-12 event-list">
    <section class="event">
      <h3>Personal Access Tokens</h3>
      {% if any(personal_tokens) %}
      <p>You have issued the following personal access tokens:</p>
      <table class="table">
        <thead>
          <tr>
            <th>Comment</th>
            <th>Issued</th>
            <th>Expires</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for token in personal_tokens %}
          <tr>
            <td>{{ token.comment }}</td>
            <td>{{ token.issued | date }}</td>
            <td>{{ token.expires | date }}</td>
            <td style="width: 6rem">
              <a
                href="{{url_for('oauth2.personal_token_revoke_GET',
                  token_id=token.id)}}"
                class="btn btn-danger btn-fill"
              >Revoke</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% else %}
      <p>You have not created any personal access tokens.</p>
      {% endif %}
      <a class="btn btn-primary" href="{{url_for('oauth2.personal_token_GET')}}">
        Generate new token {{icon("caret-right")}}
      </a>
    </section>
    <section class="event">
      <h3>Authorized Clients</h3>
      {% if any(oauth_grants) %}
      <p>You have granted the following third parties access to your account:</p>
      <table class="table">
        <thead>
          <tr>
            <th>Service</th>
            <th>Operator</th>
            <th>Issued</th>
            <th>Expires</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for grant in oauth_grants %}
          <tr>
            <td>
              {# lol this hack is awful #}
              <a
                href="{{grant.client.url}}"
                rel="noopener"
              >{{ grant.client.name }}</a>
              <a
                href="{{grant.client.url}}"
                rel="noopener"
              >{{icon('external-link-alt')}}</a>
            </td>
            <td>{{grant.client.owner.canonicalName}}</td>
            <td>{{grant.issued | date}}</td>
            <td>{{grant.expires | date}}</td>
            <td style="width: 6rem">
              <a
                href="{{url_for("oauth2.bearer_token_revoke_GET",
                  token_hash=grant.tokenHash)}}"
                class="btn btn-danger btn-fill"
              >Revoke</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% else %}
      <p>You have not granted any third party clients access to your account.</p>
      {% endif %}
    </section>
    <section class="event">
      <h3>Registered Clients</h3>
      {% if client_revoked %}
      <div class="alert alert-info">
        Your OAuth client has been unregistered. All bearer tokens issued to
        your client have been revoked.
      </div>
      {% endif %}
      <p>
        Please consult our <a
          href="https://man.sr.ht/meta.sr.ht/oauth.md"
          rel="noopener"
        >OAuth 2.0 documentation</a> for information about OAuth clients.
      </p>
      {% if any(oauth_clients) %}
      <p>You have registered the following OAuth clients:</p>
      <table class="table">
        <thead>
          <tr>
            <th>Name</th>
            <th>Client ID</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for client in oauth_clients %}
          <tr>
            <td>
              {# lol this hack is awful #}
              <a
                href="{{client.url}}"
                rel="noopener"
              >{{ client.name }}</a>
              <a
                href="{{client.url}}"
                rel="noopener"
              >{{icon('external-link-alt')}}</a>
            </td>
            <td>{{ client.uuid }}</td>
            <td style="width: 6rem">
              <a
                href="{{url_for('oauth2.manage_client_GET', uuid=client.uuid)}}"
                class="btn btn-default btn-fill"
              >Manage {{icon('caret-right')}}</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      {% else %}
      <p>You have not registered any OAuth clients yet.</p>
      {% endif %}
      <a class="btn btn-primary" href="{{url_for('oauth2.client_registration_GET')}}">
        Register new client {{icon("caret-right")}}
      </a>
    </section>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-error.html +23 -0
@@ 1,23 @@
{% extends "meta.html" %}
{% block title %}
<title>OAuth 2.0 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>OAuth 2.0 error occured</h3>
    <p>
      You're seeing this page because you were directed to a malformed OAuth
      2.0 authorization URL by a third-party client. If you are not the
      client administrator, <a href="/">click here</a> to proceed. If you are
      the client administrator, the details of this error are:
    </p>
    <dl>
      <dt>Error code</dt>
      <dd><code>{{code}}</code></dd>
      <dt>Error description</dt>
      <dd>{{description}}</dd>
    </dl>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-manage-client.html +70 -0
@@ 1,70 @@
{% extends "meta.html" %}
{% block title %}
<title>"{{client.name}}" - OAuth 2.0 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<h3>OAuth 2.0 client management</h3>
<div class="row">
  <div class="col-md-8 event-list">
    <form
      class="event"
      method="POST"
      action="{{url_for("oauth2.reissue_client_secrets_POST", uuid=client.uuid)}}"
    >
      {{csrf_token()}}
      <h3>Revoke tokens & client secret</h3>
      <p>
        If OAuth 2.0 bearer tokens issued for your OAuth client, or your client
        secret, have been disclosed to a third-party, you must revoke all
        tokens and have replacements issued.
      </p>
      <div class="row">
        <div class="col-md-5">
          <button
            class="btn btn-danger btn-block"
            type="submit"
          >Revoke client tokens {{icon('caret-right')}}</button>
        </div>
      </div>
    </form>

    <form
      class="event"
      method="POST"
      action="{{url_for("oauth2.unregister_client_POST", uuid=client.uuid)}}"
    >
      {{csrf_token()}}
      <h3>Unregister this OAuth client</h3>
      <p>
        This will permanently unregister your OAuth 2.0 client,
        "{{client.name}}", revoke all tokens issued to it, and prohibit the
        issuance of new tokens.
      </p>
      <div class="row">
        <div class="col-md-5">
          <button
            class="btn btn-danger btn-block"
            type="submit"
          >Unregister "{{client.name}}" {{icon('caret-right')}}</button>
        </div>
      </div>
    </form>
  </div>
  <div class="col-md-4">
    <dl>
      <dt>Client ID</dt>
      <dd><code>{{client.uuid}}</code></dt>
      <dt>Name</dt>
      <dd>{{client.name}}</dt>
      <dt>Description</dt>
      <dd>{{client.description}}</dt>
      <dt>Informative URL</dt>
      <dd>
        <a href="{{client.url}}" rel="nofollow noopener">{{client.url}}</a>
      </dt>
      <dt>Redirect URL</dt>
      <dd><code>{{client.redirectUrl}}</code></dt>
    </dl>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-personal-token-issued.html +22 -0
@@ 1,22 @@
{% extends "meta.html" %}
{% block title %}
<title>Personal access token registered - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <dl>
      <dt>Personal Access Token</dt>
      <dd><code>{{secret}}</code></dt>
    </dl>
    <p>
      Your access token <strong>will never be shown to you again</strong>. Keep
      this secret. It will expire {{expiry | date}}.
    </p>
    <a
      href="{{url_for('oauth2.dashboard')}}"
      class="btn btn-primary"
    >Continue {{icon('caret-right')}}</a>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-personal-token-registration.html +94 -0
@@ 1,94 @@
{% extends "meta.html" %}
{% block title %}
<title>Register personal access token - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>Personal Access Token</h3>
  </div>
</div>
<div class="row">
  <form class="col-md-10 offset-md-1" method="POST">
    {{csrf_token()}}
    <p>
      Personal access tokens are used by third-party applications and scripts
      to access to your {{cfg('sr.ht', 'site-name')}} account.
    </p>
    {% if fixed_literal_grants %}
      <div class="alert alert-info">
        The permissions for this access token have been pre-set to
        <strong>{{fixed_literal_grants}}</strong>.
      </div>
      <input type="hidden" name="literal_grants" value="{{fixed_literal_grants}}"/>
    {% else %}
      <details class=".details" {% if valid and not valid.ok %}open{% endif %}>
        <summary>Limit scope of access grant</summary>
        <div class="form-group">
          <label for="grants">Select access grants (multiple selections are permitted)</label>
          <select id="grants" name="grants" size="8" class="form-control" multiple>
            {% for group in access_grants %}
            <optgroup label="{{group['name']}}">
              {% for scope in group['scopes'] %}
              {% set val = group['name'] + "/" + scope %}
              <option
                value="{{val}}"
                {% if grants and (val + ":RO" in grants or val + ":RW" in grants) %}
                selected
                {% endif %}
              >{{scope}}</option>
              {% endfor %}
            </optgroup>
            {% endfor %}
          </select>
        </div>
        <div class="form-group">
          <label class="checkbox">
            <input
              type="checkbox"
              name="read_only"
              {% if read_only and read_only == "on" %}
              checked
              {% endif %} />
            Generate read-only access token
          </label>
        </div>
        <div class="form-group">
          <label for="literal_grants">Or use grant string</label>
          <input
            type="text"
            name="literal_grants"
            id="literal_grants"
            class="form-control {{valid.cls("literal_grants")}}"
            placeholder="meta.sr.ht/BILLING:RW meta.sr.ht/PROFILE"
            value="{{literal_grants or ""}}" />
          {{valid.summary("literal_grants")}}
        </div>
      </details>
    {% endif %}
    <div class="form-group">
      <label for="comment">Comment</label>
      <input
        type="text"
        id="comment"
        name="comment"
        class="form-control {{valid.cls("note")}}"
        aria-describedBy="comment-help" />
      <small id="comment-help" class="text-muted">
        Arbitrary comment, for personal reference only
      </small>
      {{valid.summary("comment")}}
    </div>
    <div class="alert alert-danger">
      <strong>Notice:</strong> Sharing a personal access token is similar to
      sharing your account password. Proceed with caution.
    </div>
    <button type="submit" class="btn btn-primary">
      Generate token {{icon('caret-right')}}
    </button>
    <a href="{{url_for('oauth2.dashboard')}}" class="btn btn-default">
      Cancel {{icon('caret-right')}}
    </a>
  </form>
</div>
{% endblock %}

A  => meta-custom/templates/oauth2-register-client.html +83 -0
@@ 1,83 @@
{% extends "meta.html" %}
{% block title %}
<title>Register OAuth 2.0 client - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>Register OAuth 2.0 client</h3>
  </div>
</div>
<div class="row">
  <form class="col-md-10 offset-md-1" method="POST">
    {{csrf_token()}}
    <p>
      {{cfg('sr.ht', 'site-name')}} provides API access via
      <a
        href="https://tools.ietf.org/html/rfc6749"
        rel="noopener"
      >RFC 6749</a>-compatible OAuth 2.0 confidential clients.
    </p>
    <div class="form-group">
      <label for="client_name">Client name</label>
      <input
        type="text"
        id="client_name"
        name="client_name"
        class="form-control {{valid.cls("client_name")}}"
        value="{{client_name or ""}}"
        required />
      {{valid.summary("client_name")}}
    </div>
    <div class="form-group">
      <label for="client_name">Description</label>
      <input
        type="text"
        id="client_description"
        name="client_description"
        class="form-control {{valid.cls("client_description")}}"
        value="{{client_description or ""}}" />
      {{valid.summary("client_description")}}
    </div>
    <div class="form-group">
      <label for="client_url">Informative URL</label>
      <input
        type="text"
        id="client_url"
        name="client_url"
        class="form-control {{valid.cls("client_url")}}"
        aria-describedBy="client_url-help"
        value="{{client_url or ""}}" />
      <small id="client_url-help" class="text-muted">
        Where can the user go to learn more about your client?
      </small>
      {{valid.summary("client_url")}}
    </div>
    <div class="form-group">
      <label for="redirect_uri">Redirection URI</label>
      <input
        type="text"
        id="redirect_uri"
        name="redirect_uri"
        class="form-control {{valid.cls("redirect_uri")}}"
        aria-describedBy="redirect_uri-help"
        value="{{redirect_uri or ""}}"
        required />
      <small id="redirect_uri-help" class="text-muted">
        Where should we send the user after they consent to give you API access?
        See <a
          href="https://tools.ietf.org/html/rfc6749#section-3.1.2"
          rel="noopener"
        >RFC 6749 section 3.1.2</a>.
      </small>
      {{valid.summary("redirect_uri")}}
    </div>
    <button type="submit" class="btn btn-primary">
      Register client {{icon('caret-right')}}
    </button>
    <a href="{{url_for('oauth2.dashboard')}}" class="btn btn-default">
      Cancel {{icon('caret-right')}}
    </a>
  </form>
</div>
{% endblock %}

A  => meta-custom/templates/privacy.html +61 -0
@@ 1,61 @@
{% extends "meta.html" %}
{% block title %}
<title>隐私设置 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-lg-8">
    <h3>邮件加密</h3>
    <p>
      {% if pgp_key_id %}
      所有来自 {{cfg("sr.ht", "site-name")}} 的邮件均使用以下密钥签名:<br />
      <a href="/privacy/pubkey">{{pgp_key_id}}</a>
      {% else %}
      来自 {{cfg("sr.ht", "site-name")}} 的邮件未启用加密。请联系
      {{owner.name}} &lt;<a href="mailto:{{owner.email}}">{{owner.email}}</a>&gt;
      以请求启用 PGP 邮件签名。
      {% endif %}
    </p>
    {% if any(current_user.pgp_keys) %}
    <form method="POST" action="/privacy">
      {{csrf_token()}}
      <div class="form-check">
        <label class="form-check-label">
          <input
            class="form-check-input"
            type="radio"
            name="pgp-key"
            value="null"
            {%if current_user.pgp_key_id == None%}checked{%endif%}
          /> 不加密发给我的邮件
        </label>
      </div>
      {% for key in current_user.pgp_keys %}
      <div class="form-check">
        <label class="form-check-label">
          <input
            class="form-check-input"
            type="radio"
            name="pgp-key"
            value="{{key.id}}"
            {%if current_user.pgp_key_id == key.id%}checked{%endif%}
          /> 使用 {{key.email}} {{key.fingerprint_hex}} 加密
        </label>
      </div>
      {% endfor %}
      <button type="submit" class="pull-right btn btn-primary">
        保存 {{icon("caret-right")}}
      </button>
    </form>
    <form method="POST" action="/privacy/test-email" style="clear: both; padding-top: 0.5rem">
      {{csrf_token()}}
      <button type="submit" class="pull-right btn btn-default">
        发送测试邮件 {{icon("arrow-right")}}
      </button>
    </form>
    {% elif pgp_key_id %}
    <p>如果你<a href="/keys">添加 PGP 密钥</a>到你的账户,我们可以对发给你的邮件进行加密。</p>
    {% endif %}
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/profile-delete.html +50 -0
@@ 1,50 @@
{% extends "meta.html" %}
{% block title %}
<title>删除账户 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8 offset-md-2">
    <h3>删除你的 {{cfg("sr.ht", "site-name")}} 账户</h3>
    <form method="POST" action="{{url_for(".profile_delete_POST")}}">
      {{csrf_token()}}
      <p>
        删除前,你可能需要先
        <a href="https://sr.ht/~emersion/hut">导出账户数据</a>。
        如果你选择保留用户名,将来任何人(包括你自己)都无法使用该用户名注册。
      </p>
      <div class="form-group form-check">
        <input
          type="checkbox"
          class="form-check-input"
          name="confirm"
          id="confirm">
        <label class="form-check-label" for="confirm">
          我确定要永久删除我的账户
        </label>
        {{valid.summary("confirm")}}
      </div>
      <div class="form-group form-check">
        <input
          type="checkbox"
          class="form-check-input"
          name="reserve-username"
          id="reserve-username">
        <label class="form-check-label" for="reserve-username">
          保留用户名 "{{current_user.username}}",禁止未来注册使用
        </label>
      </div>
      {{valid.summary()}}
      <div class="alert alert-danger">
        <strong>警告</strong>:点击"确认删除"将<strong>永久</strong>删除你的账户、项目和所有个人数据。此操作无法撤销。
      </div>
      <button type="submit" class="btn btn-danger">
        确认删除 {{icon('caret-right')}}
      </button>
      <a href="{{url_for(".profile_GET")}}" class="btn btn-default">
        取消 {{icon("caret-right")}}
      </a>
    </form>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/profile-deleted.html +13 -0
@@ 1,13 @@
{% extends "layout.html" %}
{% block title %}
<title>账户已删除 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8 offset-md-2" style="margin-top: 10rem">
    <div class="alert alert-success">
      你的 {{cfg("sr.ht", "site-name")}} 账户正在删除中,无需进一步操作。
    </div>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/profile.html +90 -0
@@ 1,90 @@
{% extends "meta.html" %}
{% block title %}
<title>个人资料 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-lg-6">
    <h3>编辑个人资料</h3>
    <form method="POST" action="/profile">
      {{csrf_token()}}
      <div class="form-group">
        <label for="username">
          用户名 <span class="text-muted">(不可修改)</span>
        </label>
        <input
          type="text"
          class="form-control"
          id="username"
          value="{{current_user.username}}"
          readonly />
      </div>
      <div class="form-group">
        <label for="email">邮箱地址 <span class="text-danger">*</span></label>
        <input
          type="email"
          class="form-control {{valid.cls("email")}}"
          id="email"
          name="email"
          value="{{email or current_user.email}}" />
        {% if new_email %}
        <div class="alert alert-info">
            已向 {{current_user.new_email}} 发送确认邮件。
        </div>
        {% endif %}
        {{valid.summary("email")}}
      </div>
      <div class="form-group">
        <label for="url">个人网站</label>
        <input
          type="text"
          class="form-control {{valid.cls("url")}}"
          id="url"
          name="url"
          value="{{url or current_user.url or ""}}" />
        {{valid.summary("url")}}
      </div>
      <div class="form-group">
        <label for="location">所在地</label>
        <input
          type="text"
          class="form-control {{valid.cls("location")}}"
          id="location"
          name="location"
          value="{{location or current_user.location or ""}}" />
        {{valid.summary("location")}}
      </div>
      <div class="form-group">
        <label for="bio">个人简介</label>
        <textarea
          class="form-control {{valid.cls("bio")}}"
          placeholder="支持 Markdown"
          id="bio"
          name="bio"
          rows="5">{{bio or current_user.bio or ""}}</textarea>
        {{valid.summary("bio")}}
      </div>
      <button type="submit" class="btn btn-primary pull-right">
        保存 {{icon("caret-right")}}
      </button>
    </form>
  </div>
  <div class="col-lg-6">
    <h3>导出数据</h3>
    <p>
      你可以使用
      <a href="https://sr.ht/~emersion/hut">hut 工具</a>以标准格式导出账户数据。
      导出的数据可以导入到其他 SourceHut 实例,或用于任何兼容的软件(如 git、GNU Mailman 等)。
    </p>

    <h3>注销账户</h3>
    <p>
      注销账户将永久删除你的项目和所有个人数据。
      点击下方按钮进入确认页面。
    </p>
    <a href="{{url_for(".profile_delete_GET")}}" class="btn btn-danger">
      删除我的账户 {{icon('caret-right')}}
    </a>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/register-step2.html +151 -0
@@ 1,151 @@
{% extends "layout.html" %}
{% block title %}
<title>注册 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-10 offset-md-1">
    <h3>
      注册 {{cfg("sr.ht", "site-name")}}
      <small>
        或 <a href="/login">登录</a>
      </small>
    </h3>
  </div>
</div>
{% if is_external_auth() %}
<p>注册已禁用,{{cfg("sr.ht", "site-name")}} 的认证由其他服务管理。
  请联系系统管理员了解详情。</p>
{% elif allow_registration() %}
{% if cfg("meta.sr.ht::billing", "enabled") == "yes" %}
<div class="row">
  <div class="col-md-10 offset-md-1">
    <p>
      {% if payment %}
      你正在注册为<strong>维护者</strong>。完成注册后,你将前往账单页面了解付款方式和减免政策。
      <a href="{{url_for("auth.register")}}">改为注册贡献者 {{icon('caret-right')}}</a>
      {% else %}
      你正在注册为<strong>贡献者</strong>,免费但部分功能受限。
      完成注册后,你可以随时在个人设置中转换为维护者账户。
      <a href="{{url_for("auth.register")}}">改为注册维护者 {{icon('caret-right')}}</a>
      {% endif %}
    </p>
  </div>
</div>
{% endif %}
<div class="row">
  <div class="col-md-6 offset-md-3">
    <form method="POST" action="{{url_for("auth.register_step2_POST")}}">
      {{csrf_token()}}
      <div class="form-group">
        <label for="username">用户名</label>
        <input
           type="text"
           name="username"
           id="username"
           value="{{ username }}"
           class="form-control {{valid.cls("username")}}"
           required
           autocomplete="username"
           {% if not username %} autofocus{% endif %} />
        {{valid.summary("username")}}
      </div>
      <div class="form-group">
        <label for="email">邮箱地址</label>
        <input
           type="email"
           name="email"
           id="email"
           value="{{ email }}"
           class="form-control {{valid.cls("email")}}"
           required
           {% if username and not email %} autofocus{% endif %} />
        {{valid.summary("email")}}
        {% if email and "+" in email %}
        <input type="hidden" name="allow-plus-in-email" value="yes" />
        <div class="alert alert-danger">
          <strong>警告</strong>:要正常使用 {{cfg("sr.ht",
          "site-name")}},你必须能够使用此邮箱地址收发邮件。
          如确认无误,请再次提交表单。
        </div>
        {% endif %}
      </div>
      <div class="form-group">
        <label for="password">密码</label>
        <input
           type="password"
           name="password"
           id="password"
           value="{{ password }}"
           class="form-control {{valid.cls("password")}}"
           required
           autocomplete="new-password"
           {% if username and email and not password %} autofocus{% endif %} />
        {{valid.summary("password")}}
      </div>
      {% if site_key %}
      <div class="form-group">
        <details
          {% if valid.cls("pgpKey") == "is-invalid" %}
          open
          {% endif %}
        >
          <summary>PGP 公钥(可选)</summary>
          <textarea
            class="form-control {{valid.cls("pgpKey")}}"
            id="pgpKey"
            name="pgpKey"
            style="font-family: monospace"
            rows="5"
            placeholder="gpg --armor --export-options export-minimal --export fingerprint…"
          >{{pgpKey or ""}}</textarea>
          <small class="form-text">
            {{cfg("sr.ht", "site-name")}} 发出的邮件使用以下 PGP 密钥签名:<br />
            <a href="/privacy/pubkey">{{site_key}}</a>
            <p>
              如果你在此添加 PGP 公钥,我们还会对发给你的邮件进行加密。
              你可以稍后在设置中修改。
            </p>
            <p>
              <strong class="text-danger">重要!</strong>
              如果你现在提供了 PGP 公钥,你必须能够解密确认邮件才能完成注册。
            </p>
          </small>
          {{valid.summary("pgpKey")}}
        </details>
      </div>
      {% endif %}
      <button class="btn btn-primary pull-right" type="submit">
        注册 {{icon("caret-right")}}
      </button>
      <p class="clearfix"></p>
    </form>
  </div>
</div>

<div class="row">
  <div class="col-md-8 offset-md-2">
    {{valid.summary()}}
  </div>
</div>

<div class="row">
  <div class="col-md-10 offset-md-1">
    <div class="alert alert-warning">
      <strong>隐私声明</strong>:
      {{cfg("sr.ht", "site-name")}} 仅收集提供服务所必需的最少个人信息。
      我们不会为营销或数据分析目的收集或处理你的个人信息,也不会发送营销邮件。
      你的信息仅在为提供服务所必需时才会与第三方共享,
      且在此之前会通知你并给予你阻止信息传输的机会。
      <a
        href="{{cfg("sr.ht", "privacy-policy", default="https://man.sr.ht/privacy.md")}}"
        rel="noopener"
        target="_blank"
      >隐私政策 {{icon('external-link-alt')}}</a>
    </div>
  </div>
</div>
{% else %}
<p>注册当前已关闭。</p>
{% endif %}
{% endblock %}

A  => meta-custom/templates/register.html +86 -0
@@ 1,86 @@
{% extends "layout.html" %}
{% block title %}
<title>注册 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-10 offset-md-1">
    <h3>
      注册 {{cfg("sr.ht", "site-name")}}
      <small>
        或 <a href="/login">登录</a>
      </small>
    </h3>
  </div>
</div>
{% if is_external_auth() %}
<p>注册已禁用,{{cfg("sr.ht", "site-name")}} 的认证由其他服务管理。
  请联系系统管理员了解详情。</p>
{% elif allow_registration() %}
<form
  class="row"
  action="{{url_for("auth.register_POST")}}"
  method="POST"
  style="margin-bottom: 0"
>
  {{csrf_token()}}
  <div class="col-md-5 offset-md-1 event-list">
    <div class="event">
      <h3>注册为贡献者</h3>
      <p>
        <strong>想要参与这里托管的项目?</strong>
        <br />
        你可以免费注册以参与 {{cfg("sr.ht", "site-name")}} 上托管的项目。
        如果将来你想在这里托管自己的项目,可以随时转换为付费账户。
      </p>
      <button
        type="submit"
        name="payment"
        value="no"
        class="btn btn-primary btn-block"
      >
        免费注册 {{icon("caret-right")}}
      </button>
    </div>
  </div>
  <div class="col-md-5">
    <div class="event">
      <h3>注册为维护者</h3>
      <p>
        <strong>想要在这里托管自己的项目?</strong>
        <br />
        在 {{cfg("sr.ht", "site-name")}} 上托管项目需要付费。
        有经济困难的用户可以申请减免。你可以随时取消而不会失去数据访问权限。
        <a href="https://sourcehut.org/pricing" rel="noopener" target="_blank">
          价格详情 {{icon('external-link-alt')}}
        </a>
      </p>
      <button
        type="submit"
        name="payment"
        value="yes"
        class="btn btn-primary btn-block"
      >
        付费注册 {{icon("caret-right")}}
      </button>
    </div>
  </div>
</form>

<div class="row">
  <div class="col-md-10 offset-md-1">
    <div class="alert alert-info">
      贡献者也可以不注册直接参与。你可以通过邮件提交或评论工单、参与讨论、
      向 {{cfg("sr.ht", "site-name")}} 上的项目发送补丁,无需注册账户。
      在未登录状态下,许多服务页面上都能找到通过邮件参与的链接。
    </div>
  </div>
</div>
{% else %}
<div class="row">
  <div class="col-md-10 offset-md-1">
    <p>注册当前已关闭。</p>
  </div>
</div>
{% endif %}
{% endblock %}

A  => meta-custom/templates/registered.html +19 -0
@@ 1,19 @@
{% extends "layout.html" %}
{% block title %}
<title>确认账户 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8 offset-md-2">
    <h3>
      注册成功
    </h3>
    <p>
      你将很快收到一封包含确认链接的邮件,请点击链接完成注册。
      如需帮助,请联系
      <a href="mailto:{{cfg("sr.ht", "owner-email")}}">
        {{ "{} <{}>".format(cfg("sr.ht", "owner-name"), cfg("sr.ht", "owner-email")) }}</a>。
    </p>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/reset.html +35 -0
@@ 1,35 @@
{% extends "layout.html" %}
{% block title %}
<title>重置密码 - {{cfg("sr.ht", "site-name")}}</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12">
    <h3>
      重置密码
    </h3>
  </div>
</div>
<div class="row">
  <div class="col-md-6 offset-md-6">
    <form method="POST">
      {{csrf_token()}}
      <div class="form-group">
        <label for="password">新密码</label>
        <input
           type="password"
           name="password"
           id="password"
           required
           autocomplete="new-password"
           class="form-control {{valid.cls("password")}}" />
        {{valid.summary("password")}}
      </div>
      {{valid.summary()}}
      <button class="btn btn-primary pull-right" type="submit">
        重置密码 {{icon('caret-right')}}
      </button>
    </form>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/security.html +80 -0
@@ 1,80 @@
{% extends "meta.html" %}
{% block title %}
<title>安全设置 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-12 event-list">
    <div class="event">
      <h3>两步验证</h3>
      <p>
        两步验证通过在登录时要求额外的验证步骤来增强账户安全性。
        强烈建议启用此功能。
      </p>
      <h4>TOTP</h4>
      {% if totp %}
      <div>
        <strong>已启用</strong>,启用时间:{{totp.created | date}}。
        <form method="POST" action="/security/totp/disable" class="d-inline">
          {{csrf_token()}}
          <button class="btn btn-link" type="submit">
            禁用 {{icon('caret-right')}}
          </button>
        </form>
      </div>
      {% else %}
      <p>
        <strong>未启用</strong>。启用后,每次登录时需要输入 TOTP 验证码。
      </p>
      <p>
        <a href="/security/totp/enable">
          <button class="btn btn-primary" type="submit">
            启用 TOTP
            {{icon('caret-right')}}
          </button>
        </a>
      </p>
      {% endif %}
    </div>
    <div class="event">
      <h3>修改密码</h3>
      <p>重置链接将发送到你的账户邮箱({{current_user.email}})。</p>
      {% if allow_password_reset() %}
      <form method="POST" action="/forgot">
        {{csrf_token()}}
        <input type="hidden" name="email" value="{{current_user.email}}" />
        <button class="btn btn-default" type="submit">
          发送重置链接 {{icon('caret-right')}}
        </button>
      </form>
      {% else %}
      无法重置密码,因为 {{cfg("sr.ht", "site-name")}} 的认证由其他服务管理。
      {% endif %}
    </div>
  </div>
  <section class="col-md-12">
    <h3>账户活动日志</h3>
    <table class="table">
      <thead>
        <tr>
          <th>IP 地址</th>
          <th>详情</th>
          <th>时间</th>
        </tr>
      </thead>
      <tbody>
        {% for log in audit_log %}
        <tr>
          <td>{{log.ip_address}}</td>
          <td>{{log.details or log.event_type}}</td>
          <td>{{log.created|date}}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    <a href="/security/audit/log" class="btn btn-default pull-right">
      查看完整日志 {{icon("caret-right")}}
    </a>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/tabs.html +35 -0
@@ 1,35 @@
{% macro link(path, title, cls="") %}
<a
  class="nav-link {% if request.path.startswith(path) %}active{% endif %} {{cls}}"
  href="{{ path }}">{{ title }}</a>
{% endmacro %}

<li class="nav-item">
  {{ link("/profile", "个人资料") }}
</li>
<li class="nav-item">
  {{ link("/security", "安全") }}
</li>
<li class="nav-item">
  {{ link("/keys", "密钥") }}
</li>
<li class="nav-item">
  {{ link("/privacy", "隐私") }}
</li>
<li class="nav-item">
  {{ link("/oauth2", "OAuth") }}
</li>
{% if cfg("meta.sr.ht::billing", "enabled") == "yes" %}
<li class="nav-item">
  {% if current_user.user_type == UserType.active_non_paying %}
  {{ link("/billing/initial", "账单") }}
  {% else %}
  {{ link("/billing", "账单") }}
  {% endif %}
</li>
{% endif %}
{% if current_user.user_type == UserType.admin %}
<li class="nav-item">
  {{link("/users", "用户管理", cls="text-danger")}}
</li>
{% endif %}

A  => meta-custom/templates/totp-challenge.html +48 -0
@@ 1,48 @@
{% extends "layout.html" %}
{% block title %}
<title>TOTP 验证 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8 offset-md-2">
    <h3>
      TOTP 验证
    </h3>
  </div>
</div>
<div class="row">
  <div class="col-md-8 offset-md-2">
    <p>
      {% if challenge_type == "reset" %}
      此账户已启用两步验证,你需要完成验证才能重置密码。
      {% elif challenge_type == "disable_totp" %}
      要禁用两步验证,你需要先完成验证。
      {% endif %}
      请输入 TOTP 验证码继续:
    </p>
    <form method="POST" action="/login/challenge/totp">
      {{csrf_token()}}
      <div class="form-group">
        <label for="code">验证码</label>
        <input
           type="number"
           name="code"
           id="code"
           class="form-control {{valid.cls("code")}}"
           required
           autocomplete="one-time-code"
           autofocus />
        {{valid.summary("code")}}
      </div>
      <div class="alert alert-info">
        如果你无法访问 2FA 设备,可以
        <a href="{{url_for("auth.totp_recovery_GET")}}">使用恢复码</a>。
        如仍有问题,请<a href="mailto:{{cfg('sr.ht', 'owner-email')}}">联系管理员</a>。
      </div>
      <button class="btn btn-primary" type="submit">
        继续 {{icon('caret-right')}}
      </button>
    </form>
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/totp-enable.html +41 -0
@@ 1,41 @@
{% extends "meta.html" %}
{% block title %}
<title>配置 TOTP 验证 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-8 offset-md-2">
    <h3>启用 TOTP 验证</h3>
    <p>
      使用
      <a
        href="https://f-droid.org/en/packages/com.beemdevelopment.aegis/"
        target="_blank"
      >Aegis</a> 等应用扫描下方二维码,然后输入生成的验证码以启用 TOTP。
    </p>
    <div class="col-md-12 text-centered">
      <img src="{{qrcode}}" alt="{{secret}}" title="{{otpauth_uri}}"/><br>
      <a href="{{otpauth_uri}}"><small>{{otpauth_uri}}</small></a>
    </div>
    <form method="POST" action="/security/totp/enable">
      {{csrf_token()}}
      <input type="hidden" name="secret" value="{{secret}}" />
      <div class="form-group">
        <input
          type="number"
          id="code"
          name="code"
          class="form-control {{valid.cls("code")}}"
          placeholder="123456"
          required
          autocomplete="one-time-code"
          autofocus />
        {{valid.summary("code")}}
      </div>
      <button type="submit" class="btn btn-primary pull-right">
        启用 {{icon("caret-right")}}
      </button>
    </form>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/totp-enabled.html +25 -0
@@ 1,25 @@
{% extends "meta.html" %}
{% block title %}
<title>TOTP 设置完成 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <section class="col-md-8 offset-md-2">
    <h3>TOTP 已启用</h3>
    <p>
      请保存以下恢复码,以防丢失 TOTP 设备时使用。
    </p>
<pre class="text-center">{% for code in codes -%}
{{code}}
{% endfor %}</pre>
    <p>
      建议你同时保持账户中的 SSH 和 PGP 公钥为最新状态,
      以便在需要账户恢复时作为辅助验证方式。
    </p>
    <a
      href="{{url_for("security.security_GET")}}"
      class="btn btn-primary"
    >继续 {{icon('caret-right')}}</a>
  </section>
</div>
{% endblock %}

A  => meta-custom/templates/totp-recovery.html +46 -0
@@ 1,46 @@
{% extends "layout.html" %}
{% block title %}
<title>TOTP 恢复 - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <div class="col-md-8">
    <h3>
      TOTP 恢复
    </h3>
  </div>
</div>
<div class="row">
  <div class="col-md-8">
    {% if supported %}
    <p>
      请输入一个 TOTP 恢复码继续:
    </p>
    <form method="POST">
      {{csrf_token()}}
      <div class="form-group">
        <label for="recovery-code">恢复码</label>
        <input
           type="text"
           name="recovery-code"
           id="recovery-code"
           class="form-control {{valid.cls("recovery-code")}}"
           required
           autocomplete="one-time-code"
           autofocus />
        {{valid.summary("recovery-code")}}
      </div>
      <p>提交后将禁用你账户上的 TOTP。</p>
      <button class="btn btn-primary" type="submit">
        继续 {{icon('caret-right')}}
      </button>
    </form>
    {% else %}
    <div class="alert alert-danger">
      你的 TOTP 在恢复码功能推出前配置,无法使用恢复码。
      请<a href="mailto:{{cfg('sr.ht', 'owner-email')}}">联系管理员</a>处理。
    </div>
    {% endif %}
  </div>
</div>
{% endblock %}

A  => meta-custom/templates/user.html +531 -0
@@ 1,531 @@
{% extends "meta.html" %}
{% block title %}
<title>~{{user.username}} - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="alert alert-danger">
  <strong>Notice</strong>: This page contains private user information.
  Remember your committment to protecting the privacy of the person listed
  here and do not share any of this information with unauthorized
  individuals. Don't fall for spear phishing &mdash; double check that you've
  received an authentic support request before doing anything on the user's
  behalf!
</div>
<h3>~{{user.username}}</h3>
<div class="row">
  <section class="col-md-12">
    <dl class="row">
      <dt class="col-md-3">User ID</dt>
      <dd class="col-md-9">{{user.id}}</dd>
      <dt class="col-md-3">Email</dt>
      <dd class="col-md-9">
        <a href="mailto:{{user.email}}">{{user.email}}</a>
      </dd>
      <dt class="col-md-3">Registered</dt>
      <dd class="col-md-9">{{user.created | date}}</dd>
      <dt class="col-md-3">User type</dt>
      <dd class="col-md-9">{{user.user_type.value}}</dd>
      {% if user.user_type.value == "suspended" %}
      <dt class="col-md-3">Suspension Notice</dt>
      <dd class="col-md-9">{{user.suspension_notice}}</dd>
      {% endif %}

      {% if user.location %}
      <dt class="col-md-3">Location</dt>
      <dd class="col-md-9">{{user.location}}</dd>
      {% endif %}
      {% if user.url %}
      <dt class="col-md-3">URL</dt>
      <dd class="col-md-9">
        <a
          href="{{user.url}}"
          target="_blank"
          rel="me noopener noreferrer nofollow"
        >{{user.url}}</a>
      </dd>
      {% endif %}

      {% if cfg("meta.sr.ht::billing", "enabled") == "yes"
        and user.stripe_customer %}
      <dt class="col-md-3">Stripe customer</dt>
      <dd class="col-md-9">
        <a href="https://dashboard.stripe.com/customers/{{user.stripe_customer}}">
          {{user.stripe_customer}}
        </a>
      </dd>
      <dt class="col-md-3">Payment amount</dt>
      <dd class="col-md-9">
        ${{"{:.2f}".format(user.payment_cents / 100)}}
      </dd>
      <dt class="col-md-3">Payment interval</dt>
      <dd class="col-md-9">
        {{user.payment_interval.value}}
      </dd>
      <dt class="col-md-3">Payment due</dt>
      <dd class="col-md-9">{{user.payment_due | date}}</dd>
      {% endif %}
    </dl>
    {% if user.bio %}
    <blockquote>
      {{user.bio | md}}
    </blockquote>
    {% endif %}
    {% if reset_pending %}
    <div class="alert alert-warning">
      <a
        href="{{url_for("auth.reset_GET", token=user.reset_hash)}}"
        class="btn btn-link pull-right"
      >reset link</a>
      Password reset pending.
    </div>
    {% endif %}
    <form
      method="POST"
      action="{{url_for("users.set_user_type", username=user.username)}}"
      class="alert alert-info"
    >
      {{csrf_token()}}
      <button
        type="submit"
        class="btn btn-primary btn-sm pull-right"
      >
        Update
      </button>
      <select
        name="user_type"
        class="form-control"
        style="display: inline; width: inherit"
      >
        {% for type in [
          "unconfirmed", "active_non_paying", "active_free", "active_paying",
          "active_delinquent", "admin" ] %}
          <option
            value="{{type}}"
            {% if user.user_type.value == type %}
            selected
            {% endif %}
          >{{type}}</option>
        {% endfor %}
        {% if user.user_type.value == "suspended" %}
          {# You can remove this here, but not set it #}
          <option value="suspended" selected>suspended</option>
        {% endif %}
      </select>
    </form>
    {% if totp %}
    <form
      method="POST"
      action="{{url_for("users.user_disable_totp", username=user.username)}}"
      class="alert alert-warning"
    >
      {{csrf_token()}}
      <button type="submit" class="btn btn-link pull-right">
        Disable TOTP
      </button>
      This account has TOTP enabled.
    </form>
    {% endif %}

    <form
      method="POST"
      action="{{url_for("users.user_delete_POST", username=user.username)}}"
      class="alert alert-danger"
    >
      {{csrf_token()}}
      Check both boxes to delete this account:
      <button type="submit" class="btn btn-danger pull-right">
        Delete account
	{{icon('caret-right')}}
      </button>
      <input type="checkbox" id="safe-1" name="safe-1">
      <label class="form-check-label" for="safe-1">
        Confirm once
      </label>
      <input type="checkbox" id="safe-2" name="safe-2">
      <label class="form-check-label" for="safe-2">
        Confirm twice
      </label>
    </form>
  </section>
</div>
<div class="row">
  <form
    class="col-md-12"
    action="{{url_for('.user_add_note', username=user.username)}}"
    method="POST"
  >
    <h3>User notes</h3>
    <div class="event-list">
      {% for note in user.notes %}
      <div class="event">
        {{note.note}}
        <span class="text-muted">{{note.created | date}}</span>
      </div>
      {% endfor %}
    </div>
    <div class="form-group">
      <textarea
        id="notes"
        name="notes"
        class="form-control {{valid.cls('notes')}}"
        rows="3"
      >{{notes if notes else ""}}</textarea>
      {{valid.summary('notes')}}
    </div>
    {{csrf_token()}}
    <button type="submit" class="btn btn-primary pull-right">
      Add note
      {{icon('caret-right')}}
    </button>
  </form>
</div>
<div class="row">
  <form
    class="col-md-12"
      method="POST"
      action="{{url_for("users.user_suspend", username=user.username)}}"
    >
      <h3>Suspend account</h3>
      {{csrf_token()}}
      <div class="form-group">
        <input
          type="text"
          placeholder="Suspension reason (shown to user)"
          class="form-control"
          name="reason" />
      </div>
      <button type="submit" class="btn btn-danger pull-right">
        Suspend user
        {{icon('caret-right')}}
      </button>
  </form>
</div>
<div class="row">
  <div class="col-md-12">
    {% if any(user.ssh_keys) %}
    <h3>SSH keys</h3>
    <table class="table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Fingerprint</th>
          <th>Authorized</th>
          <th>Last Used</th>
        </tr>
      </thead>
      <tbody>
        {% for key in user.ssh_keys %}
        <tr>
          <td>{{key.comment}}</td>
          <td>
            <details>
              <summary>{{key.fingerprint}}</summary>
              <pre style="max-width: 600px">{{key.key}}</pre>
            </details>
          </td>
          <td>{{key.created|date}}</td>
          <td>{{key.last_used|date}}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% endif %}
    {% if any(user.pgp_keys) %}
    <h3>PGP keys</h3>
    <table class="table">
      <thead>
        <tr>
          <th>ID</th>
          <th>Fingerprint</th>
          <th>Authorized</th>
        </tr>
      </thead>
      <tbody>
        {% for key in user.pgp_keys %}
        <tr>
          <td>{{key.id}}</td>
          <td>
            <details>
              <summary>{{key.fingerprint_hex}}</summary>
              <pre style="max-width: 600px">{{key.key}}</pre>
            </details>
          </td>
          <td>{{key.created|date}}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
    {% endif %}
    {% if user.confirmation_hash %}
    <div class="alert alert-warning">
      <a
        href="{{url_for("auth.confirm_account", token=user.confirmation_hash)}}"
        class="btn btn-link pull-right"
      >confirmation link</a>
      This account is pending confirmation.
    </div>
    {% endif %}
  </div>
</div>
<div class="row">
  <form
    class="col-md-12"
      method="POST"
      action="{{url_for("users.user_invoice", username=user.username)}}"
    >
      <h3>Issue account credit</h3>
      {{csrf_token()}}
      <div class="row">
        <div class="form-group col-md-6">
          <label for="amount">Amount (in dollars)</label>
          <input
            type="number"
            placeholder="Amount"
            class="form-control"
            id="amount"
            name="amount"
            value="20" />
        </div>
        <div class="form-group col-md-6">
          <label for="comment">Valid thru</label>
          <input
            type="date"
            class="form-control"
            name="valid_thru"
            value="{{one_year.strftime("%Y-%m-%d")}}" />
        </div>
      </div>
      <div class="form-group">
        <label for="source">Source ("paid with..." in billing UI)</label>
        <input
          type="text"
          placeholder="e.g. 'PayPal'"
          class="form-control"
          name="source" />
      </div>
      <button type="submit" class="btn btn-primary pull-right">
        Issue invoice
        {{icon('caret-right')}}
      </button>
  </form>
</div>
{% if user.user_type.value == "active_paying" %}
<div class="row">
  <form
    class="col-md-12"
    action="{{url_for('.user_transfer_billing', username=user.username)}}"
    method="POST"
  >
    <h3>Transfer billing information</h3>
    <div class="form-group">
      <label for="target">New user</label>
      <input
        type="text" 
        id="target"
        name="target"
        class="form-control {{valid.cls('target')}}"
        value="{{target or "" }}" />
      {{valid.summary('target')}}
    </div>
    {{csrf_token()}}
    <button type="submit" class="btn btn-primary pull-right">
      Transfer billing
      {{icon('caret-right')}}
    </button>
  </form>
</div>
{% endif %}
<div class="row">
  <section class="col-md-12">
    <h3>Audit Log</h3>
    <table class="table">
      <thead>
        <tr>
          <th>IP Address</th>
          <th>Host</th>
          <th>Details</th>
          <th>Date</th>
        </tr>
      </thead>
      <tbody>
        {% for log in audit_log %}
        <tr>
          <td>{{log.ip_address}}</td>
          <td>{{rdns.get(log.ip_address.exploded, "unknown")}}</td>
          <td>{{log.details or log.event_type}}</td>
          <td>{{log.created | date }}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  </section>
</div>
<div class="row">
  <div class="col-md-12 event-list">
    <section>
      <h3>Personal Access Tokens</h3>
      <table class="table">
        <thead>
          <tr>
            <th>Comment</th>
            <th>Issued</th>
            <th>Expires</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for token in personal_tokens %}
          <tr>
            <td>{{ token.comment }}</td>
            <td>{{ token.issued | date }}</td>
            <td>{{ token.expires | date }}</td>
            <td style="width: 6rem">
              <a
                href="{{url_for('oauth2.personal_token_revoke_GET',
                  token_id=token.id)}}"
                class="btn btn-danger btn-fill"
              >Revoke</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
    </section>
    <section>
      <h3>Authorized Clients</h3>
      <table class="table">
        <thead>
          <tr>
            <th>Service</th>
            <th>Operator</th>
            <th>Issued</th>
            <th>Expires</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for grant in oauth_grants %}
          <tr>
            <td>
              {# lol this hack is awful #}
              <a
                href="{{grant.client.url}}"
                rel="noopener"
              >{{ grant.client.name }}</a>
              <a
                href="{{grant.client.url}}"
                rel="noopener"
              >{{icon('external-link-alt')}}</a>
            </td>
            <td>{{grant.client.owner.canonicalName}}</td>
            <td>{{grant.issued | date}}</td>
            <td>{{grant.expires | date}}</td>
            <td style="width: 6rem">
              <a
                href="{{url_for("oauth2.bearer_token_revoke_GET",
                  token_hash=grant.tokenHash)}}"
                class="btn btn-danger btn-fill"
              >Revoke</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
    </section>
    <section>
      <h3>Registered Clients</h3>
      <table class="table">
        <thead>
          <tr>
            <th>Name</th>
            <th>Client ID</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {% for client in oauth_clients %}
          <tr>
            <td>
              {# lol this hack is awful #}
              <a
                href="{{client.url}}"
                rel="noopener"
              >{{ client.name }}</a>
              <a
                href="{{client.url}}"
                rel="noopener"
              >{{icon('external-link-alt')}}</a>
            </td>
            <td>{{ client.uuid }}</td>
            <td style="width: 6rem">
              <a
                href="{{url_for('oauth2.manage_client_GET', uuid=client.uuid)}}"
                class="btn btn-default btn-fill"
              >Manage {{icon('caret-right')}}</a>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
    </section>
    <section>
      <h3>Invoices</h3>
      <div class="event-list">
        {% for invoice in invoices %}
        <div class="event invoice">
          <h4>
            <small class="text-success" style="margin-left: 0">
              {{icon('check')}}
            </small>
            ${{"{:.2f}".format(invoice.cents/100)}}
            <small>
              with {{invoice.source}}
            </small>
            <small>
              <a
                class="pull-right"
                href="{{url_for("billing.invoice_GET", invoice_id=invoice.id)}}"
              >Export as PDF</a>
            </small>
          </h4>
          <p>
            Paid {{invoice.created | date}}<br />
            Valid for service until {{invoice.valid_thru | date}}
            {% if invoice.comment %}
            <br />
            {{invoice.comment}}
            {% endif %}
          </p>
        </div>
        {% endfor %}
      </div>
    </section>
  </div>
  <form
    class="col-md-8"
    action="{{url_for("users.user_impersonate_POST", username=user.username)}}"
    method="POST"
  >
    {{csrf_token()}}
    <h3>Impersonate user</h3>
    <div class="form-group">
      <label for="reason">Reason</label>
      <input
        type="text" 
        class="form-control {{valid.cls('reason')}}"
        id="reason"
        name="reason"
        value="{{reason if reason else ""}}" />
      {{valid.summary('reason')}}
    </div>
    <div class="alert alert-danger">
      This will send a security notification to the user and the admin security
      mailing list. You must have the user's permission to use this feature.
    </div>
    <button type="submit" class="btn btn-primary">
      Impersonate this user
      {{icon('caret-right')}}
    </button>
  </form>
</div>
{% endblock %}

A  => meta-custom/templates/users.html +54 -0
@@ 1,54 @@
{% extends "meta.html" %}
{% block title %}
<title>User admin - {{cfg("sr.ht", "site-name")}} meta</title>
{% endblock %}
{% block content %}
<div class="row">
  <form class="col-md-12">
    <h3>User administration</h3>
    <div class="form-group">
      <input
        type="text"
        class="form-control{% if search_error %} is-invalid{% endif %}"
        name="search"
        placeholder="Search by username or email"
        value="{{search if search else ""}}" />
      {% if search_error %}
        <div class="invalid-feedback">{{ search_error }}</div>
      {% endif %}
    </div>
    <div class="alert alert-danger">
      <strong>Notice</strong>: This page contains private user information.
      Remember your committment to protecting the privacy of the people listed
      here and do not share any of this information with unauthorized
      individuals.
    </div>
    <div class="event-list">
      {% for user in users %}
      <div class="event">
        <h3>
          <a href="{{url_for(".user_by_username_GET", username=user.username)}}">
            ~{{user.username}}
          </a>
        </h3>
        <dl class="row">
          <dt class="col-md-3">Email</dt>
          <dd class="col-md-9">
            <a href="mailto:{{user.email}}">{{user.email}}</a>
          </dd>
          <dt class="col-md-3">Registered</dt>
          <dd class="col-md-9">{{user.created | date}}</dd>
          <dt class="col-md-3">User type</dt>
          <dd class="col-md-9">{{user.user_type.value}}</dd>
          {% if user.bio %}
          <dt class="col-md-3">Bio</dt>
          <dd class="col-md-9">{{user.bio | md}}</dd>
          {% endif %}
        </dl>
      </div>
      {% endfor %}
    </div>
    {{pagination()}}
  </form>
</div>
{% endblock %}

A  => todo-custom/Dockerfile +84 -0
@@ 1,84 @@
# === Stage 1: Build core.sr.ht + todo.sr.ht from source ===
# k8ieone 不提供 todo.sr.ht 镜像,需从源码构建
FROM docker.io/library/alpine:3.20 AS builder

RUN adduser -D builder
# 启用 community 仓库(py3-graphql-core 等依赖需要)
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.20/community" >> /etc/apk/repositories
RUN apk add git alpine-sdk sudo nodejs npm go sassc minify
RUN echo "builder ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/builder \
    && chmod 0440 /etc/sudoers.d/builder
RUN sudo -u builder abuild-keygen -a -i -n
RUN addgroup builder abuild

RUN git clone https://git.sr.ht/~sircmpwn/sr.ht-apkbuilds
RUN chown -R builder:builder /sr.ht-apkbuilds

# --- core.sr.ht 依赖 ---
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-mistletoe
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-celery
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-infinity
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-intervals
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-orderedmultidict
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-furl
RUN sudo -u builder abuild -r -K

# --- core.sr.ht(同时产出 core.sr.ht-dev、py3-srht 子包)---
WORKDIR /sr.ht-apkbuilds/sr.ht/core.sr.ht
RUN sudo -u builder abuild checksum
RUN sudo -u builder abuild -r -K

# --- todo.sr.ht 额外构建依赖 ---
# py3-graphql-core 在 Alpine 3.20 不存在,从 edge 安装
RUN apk add --repository https://dl-cdn.alpinelinux.org/alpine/edge/community py3-graphql-core
WORKDIR /sr.ht-apkbuilds/sr.ht/py3-autoflake
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/ariadne-codegen
# Alpine 3.20 的 hatchling 太旧,不支持新版 license-files 格式
RUN apk add py3-pip && pip3 install --break-system-packages hatchling --upgrade
RUN sudo -u builder abuild -r -K
WORKDIR /sr.ht-apkbuilds/sr.ht/sourcehut-migrate
RUN sudo -u builder abuild -r -K

# --- todo.sr.ht ---
WORKDIR /sr.ht-apkbuilds/sr.ht/todo.sr.ht
RUN sudo -u builder abuild checksum
RUN sudo -u builder abuild -r -K


# === Stage 2: Runner ===
FROM docker.io/library/alpine:3.20

RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.20/community" >> /etc/apk/repositories

COPY --from=builder /home/builder/packages /home/builder/packages
COPY --from=builder /etc/apk/keys/builder* /etc/apk/keys/

RUN apk add --repository /home/builder/packages/sr.ht todo.sr.ht
RUN apk add nginx sudo py3-alembic py3-pip
# celery 5.5.3 需要 kombu >= 5.4,Alpine 3.20 只有 5.3.7
RUN pip3 install --break-system-packages 'kombu>=5.4'

# core.sr.ht 0.78.6 重命名了部分 API,todo.sr.ht 0.77.5 仍使用旧名
COPY compat_oauth.py /tmp/compat_oauth.py
RUN python3 /tmp/compat_oauth.py && rm /tmp/compat_oauth.py

RUN adduser -D srht

# --- Jinja2 修复 ---
COPY fix_jinja2_do.py /tmp/fix_jinja2_do.py
RUN python3 /tmp/fix_jinja2_do.py && rm /tmp/fix_jinja2_do.py

# 创建 nginx 静态文件符号链接(适配 Python 版本)
RUN PYVER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') \
    && ln -sf /usr/lib/python${PYVER}/site-packages/todosrht /srv/todosrht

COPY start.sh /start.sh
COPY nginx.conf /etc/nginx/http.d/default.conf

CMD ["/bin/sh", "/start.sh"]

A  => todo-custom/compat_oauth.py +61 -0
@@ 1,61 @@
"""Compatibility patches for core.sr.ht 0.78.6 + todo.sr.ht 0.77.5.

core.sr.ht 0.78.6 refactored the OAuth API:
- AbstractOAuthService removed, replaced by OAuthService (different signature)
- DelegatedScope removed, replaced by OAuthScope (different signature)
- SrhtFlask no longer accepts oauth_service=, uses user_class= instead
"""
import glob

# 1. Patch srht/oauth/__init__.py: add compat aliases
OAUTH_PATCH = '''

# --- compat shim for todo.sr.ht 0.77.5 ---
class AbstractOAuthService(OAuthService):
    """Compat wrapper: old API accepted (client_id, client_secret, delegated_scopes=, token_class=, user_class=)"""
    def __init__(self, client_id=None, client_secret=None,
                 delegated_scopes=None, token_class=None, user_class=None, **kwargs):
        super().__init__("todo.sr.ht",
                         user_class=user_class,
                         oauthtoken_class=token_class)
        self.delegated_scopes = delegated_scopes or []

class DelegatedScope:
    def __init__(self, name, description=None, write=False):
        self.name = name
        self.description = description
        self.write = write
# --- end compat shim ---
'''

for f in glob.glob("/usr/lib/python3.*/site-packages/srht/oauth/__init__.py"):
    with open(f) as fh:
        content = fh.read()
    if "DelegatedScope" not in content:
        with open(f, "a") as fh:
            fh.write(OAUTH_PATCH)
        print(f"Patched {f}: added AbstractOAuthService + DelegatedScope compat")
    else:
        print(f"Already patched: {f}")

# 2. Patch todosrht/flask.py: use new SrhtFlask API
#    Old: super().__init__("todo.sr.ht", __name__, oauth_service=TodoOAuthService())
#    New: super().__init__("todo.sr.ht", __name__, user_class=User, legacy_oauthtoken_class=OAuthToken)
for f in glob.glob("/usr/lib/python3.*/site-packages/todosrht/flask.py"):
    with open(f) as fh:
        content = fh.read()
    if "oauth_service=TodoOAuthService()" in content:
        content = content.replace(
            "oauth_service=TodoOAuthService())",
            "user_class=User, legacy_oauthtoken_class=OAuthToken)"
        )
        # Add OAuthToken import
        content = content.replace(
            "from todosrht.types import TicketAccess, TicketStatus, TicketResolution, User",
            "from todosrht.types import TicketAccess, TicketStatus, TicketResolution, User, OAuthToken"
        )
        with open(f, "w") as fh:
            fh.write(content)
        print(f"Patched {f}: switched to new SrhtFlask API")
    else:
        print(f"Already patched: {f}")

A  => todo-custom/fix_jinja2_do.py +33 -0
@@ 1,33 @@
"""Enable Jinja2 'do' extension in sourcehut Flask app.

Upstream fix: https://lists.sr.ht/~sircmpwn/sr.ht-dev/patches/39036
"""
import glob

candidates = glob.glob("/usr/lib/python3.*/site-packages/srht/flask.py")
if not candidates:
    raise FileNotFoundError("Cannot find srht/flask.py")

source_file = candidates[0]

with open(source_file) as f:
    content = f.read()

target = "self.jinja_loader = ChoiceLoader"
patch = '        self.jinja_env.add_extension("jinja2.ext.do")'

if "jinja2.ext.do" not in content:
    lines = content.split("\n")
    patched = False
    for i, line in enumerate(lines):
        if target in line:
            lines.insert(i + 1, patch)
            patched = True
            break
    if not patched:
        raise RuntimeError(f"Could not find '{target}' in {source_file}")
    with open(source_file, "w") as f:
        f.write("\n".join(lines))
    print(f"Patched {source_file}: enabled jinja2.ext.do")
else:
    print(f"Already patched: {source_file}")

A  => todo-custom/nginx.conf +16 -0
@@ 1,16 @@
server {
    listen 8080 default_server;
    listen [::]:8080 default_server;

    location / {
        proxy_pass http://127.0.0.1:5003;
    }

    location /query {
        proxy_pass http://127.0.0.1:5103;
    }

    location /static {
        root /usr/share/sourcehut/;
    }
}

A  => todo-custom/start.sh +31 -0
@@ 1,31 @@
#!/bin/sh
set -e

# 初始化数据库表结构 + 迁移
todo.sr.ht-initdb
sr.ht-migrate todo.sr.ht upgrade head
todo.sr.ht-migrate upgrade head

# todo.sr.ht web
mkdir -p /run/todo.sr.ht
chown -R srht:srht /run/todo.sr.ht
chmod 775 /run/todo.sr.ht
sudo -u srht prometheus_multiproc_dir=/run/todo.sr.ht \
    /usr/bin/gunicorn todosrht.app:app \
    -b 0.0.0.0:5003 &

# todo.sr.ht GraphQL API
sudo -u srht /usr/bin/todo.sr.ht-api \
    -b 0.0.0.0:5103 &

# todo.sr.ht webhooks worker
sudo -u srht /usr/bin/celery \
    -A todosrht.webhooks worker \
    --loglevel=info &

# todo.sr.ht LMTP(邮件创建工单,需额外配置 MX 记录)
# sudo -u srht /usr/bin/todo.sr.ht-lmtp &

nginx &

tail -f /dev/null