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>
+ —
+ <a href="{{logout_url}}">登出</a>
+ </span>
+ {% else %}
+ <span class="navbar-text">
+ {% if site == 'meta.sr.ht' %}
+ <a href="/login">登录</a>
+ —
+ <a href="/">注册</a>
+ {% else %}
+ <a href="{{ oauth_url }}" rel="nofollow">登录</a>
+ —
+ <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 => +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 & 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 & 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}} <<a href="mailto:{{owner.email}}">{{owner.email}}</a>>
+ 以请求启用 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 — 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