我的空间 / 阅读概览
L
正在连接微信读书…阅读目标跨浏览器保存
diff --git a/app.py b/app.py index b9b60ec..24e6f16 100644 --- a/app.py +++ b/app.py @@ -37,6 +37,7 @@ except ImportError: _ssl_context = ssl.create_default_context() from flask import Flask, g, jsonify, render_template, request +from auth import register_auth from reading_api import reading_bp # ====================================================================== @@ -46,6 +47,7 @@ app = Flask(__name__, static_folder="static", template_folder="templates") # 注册当前看板数据接口,并保留旧客户端兼容路径。 app.register_blueprint(reading_bp) +register_auth(app) # ====================================================================== # 数据库连接配置 diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..fddbeb4 --- /dev/null +++ b/auth.py @@ -0,0 +1,186 @@ +import hmac +import os +import secrets +from datetime import timedelta + +import pymysql +from flask import Blueprint, current_app, jsonify, redirect, render_template, request, session, url_for +from werkzeug.security import check_password_hash, generate_password_hash + +from reading_api import MYSQL_CONFIG + +auth_bp = Blueprint("auth", __name__) +_DUMMY_HASH = generate_password_hash("not-a-real-password") + + +def get_account(): + with pymysql.connect(**MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + cur.execute("SELECT username, password_hash FROM weread_auth WHERE id = 1") + return cur.fetchone() + + +def create_account(username, password_hash): + try: + with pymysql.connect(**MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO weread_auth (id, username, password_hash) VALUES (1, %s, %s)", + (username, password_hash), + ) + conn.commit() + return True + except pymysql.IntegrityError: + return False + + +def valid_csrf(token): + expected = session.get("csrf_token", "") + return bool(expected and token and hmac.compare_digest(expected.encode(), token.encode())) + + +def csrf_token(): + if not current_app.secret_key: + return "" + if "csrf_token" not in session: + session["csrf_token"] = secrets.token_urlsafe(32) + return session["csrf_token"] + + +def start_session(username): + session.clear() + session.permanent = True + session["auth_user"] = username + csrf_token() + + +def database_error(): + return render_template( + "login.html", error="登录数据库暂时不可用,请稍后重试。", unavailable=True, + ), 503 + + +@auth_bp.get("/login") +def login_page(): + if not current_app.secret_key: + return render_template( + "login.html", error="服务未配置会话密钥,请联系管理员。", unavailable=True, + ), 503 + try: + initialized = get_account() is not None + except pymysql.MySQLError: + return database_error() + return render_template( + "login.html", + initialized=initialized, + setup_enabled=bool(current_app.config.get("AUTH_SETUP_TOKEN")), + error=request.args.get("error", ""), + ) + + +@auth_bp.post("/auth/initialize") +def initialize_account(): + if not valid_csrf(request.form.get("csrf_token")): + return render_template( + "login.html", initialized=False, setup_enabled=bool(current_app.config.get("AUTH_SETUP_TOKEN")), + error="页面已过期,请刷新后重试。", + ), 400 + try: + if get_account() is not None: + return redirect(url_for("auth.login_page")) + except pymysql.MySQLError: + return database_error() + + setup_token = current_app.config.get("AUTH_SETUP_TOKEN", "") + supplied_token = request.form.get("setup_token", "") + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not setup_token or not hmac.compare_digest(setup_token.encode(), supplied_token.encode()): + return render_template("login.html", initialized=False, setup_enabled=bool(setup_token), error="初始化口令无效或尚未配置。"), 403 + if not username or len(username) > 64 or len(password) < 12 or len(password) > 256: + return render_template( + "login.html", initialized=False, setup_enabled=True, + error="用户名需为 1-64 个字符,密码需为 12-256 个字符。", + ), 400 + + try: + if not create_account(username, generate_password_hash(password)): + return redirect(url_for("auth.login_page")) + except pymysql.MySQLError: + return database_error() + + current_app.config["AUTH_SETUP_TOKEN"] = "" + start_session(username) + return redirect("/") + + +@auth_bp.post("/auth/login") +def login(): + if not valid_csrf(request.form.get("csrf_token")): + return render_template("login.html", initialized=True, error="页面已过期,请刷新后重试。"), 400 + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if len(password) > 256: + return render_template("login.html", initialized=True, error="账号或密码不正确。"), 401 + try: + account = get_account() + except pymysql.MySQLError: + return database_error() + + stored_hash = account["password_hash"] if account else _DUMMY_HASH + password_matches = check_password_hash(stored_hash, password) + username_matches = account and hmac.compare_digest(account["username"].encode(), username.encode()) + if not account or not username_matches or not password_matches: + return render_template("login.html", initialized=True, error="账号或密码不正确。"), 401 + start_session(account["username"]) + return redirect("/") + + +@auth_bp.post("/auth/logout") +def logout(): + if not valid_csrf(request.form.get("csrf_token")): + return "页面已过期,请刷新后重试。", 400 + session.clear() + return redirect(url_for("auth.login_page")) + + +def register_auth(app): + app.config.update( + SECRET_KEY=os.environ.get("WEREAD_SESSION_SECRET") or None, + AUTH_SETUP_TOKEN=os.environ.get("WEREAD_AUTH_SETUP_TOKEN", ""), + PERMANENT_SESSION_LIFETIME=timedelta(days=5), + SESSION_REFRESH_EACH_REQUEST=False, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=( + os.environ.get("WEREAD_SESSION_COOKIE_SECURE", "1").lower() + not in {"0", "false", "no"} + ), + ) + app.register_blueprint(auth_bp) + app.context_processor(lambda: {"csrf_token": csrf_token}) + + @app.before_request + def require_login_and_csrf(): + if request.endpoint == "static": + return None + if not app.secret_key: + if request.endpoint == "auth.login_page": + return None + if request.path.startswith("/api/"): + return jsonify(error="服务未配置会话密钥"), 503 + return render_template( + "login.html", unavailable=True, error="服务未配置会话密钥,请联系管理员。", + ), 503 + if request.endpoint in {"auth.login_page", "auth.login", "auth.initialize_account", "auth.logout"}: + return None + if not session.get("auth_user"): + if request.path.startswith("/api/"): + return jsonify(error="需要登录"), 401 + return redirect(url_for("auth.login_page")) + if request.method in {"POST", "PUT", "PATCH", "DELETE"}: + if not valid_csrf(request.headers.get("X-CSRF-Token")): + if request.path.startswith("/api/"): + return jsonify(error="CSRF token 无效"), 400 + return "页面已过期,请刷新后重试。", 400 + return None diff --git a/deploy.sh b/deploy.sh index 31b2da3..d31a798 100755 --- a/deploy.sh +++ b/deploy.sh @@ -73,14 +73,13 @@ import pymysql from reading_api import MYSQL_CONFIG -migration = Path("migrations/001_reading_goals.sql") -if migration.exists(): - with pymysql.connect(**MYSQL_CONFIG) as conn: - with conn.cursor() as cur: +with pymysql.connect(**MYSQL_CONFIG) as conn: + with conn.cursor() as cur: + for migration in sorted(Path("migrations").glob("*.sql")): for sql in migration.read_text(encoding="utf-8").split(";"): if sql.strip(): cur.execute(sql) - conn.commit() + conn.commit() PY echo "[5/5] 重启服务 $SERVICE_NAME" diff --git a/docs/code-design.md b/docs/code-design.md index 466e7c8..50560b8 100644 --- a/docs/code-design.md +++ b/docs/code-design.md @@ -45,10 +45,20 @@ GitHub Actions 的 `.github/workflows/deploy.yml` 只监听 `master` 分支。分支 push 后,Actions 使用仓库 Secrets 中的服务器地址、用户和 SSH 私钥登录服务器,进入 `/opt/weread-board/WeReadBoard` 后调用 `deploy.sh`。手动触发也固定部署 `master`,部署脚本会拒绝其他分支。 -`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,执行 `migrations/001_reading_goals.sql`,最后重启 `weread.service`。 +`deploy.sh` 固定拉取 `master`,收到其他部署分支参数时直接退出,再检查项目 `.venv`。如果 `.venv/bin/python` 不存在,脚本使用服务器上的 `python3` 或 `python` 创建虚拟环境;随后用 `.venv/bin/python -m pip install -r requirements.txt` 安装依赖,按文件名顺序执行 `migrations/*.sql`,最后重启 `weread.service`。 例:用户将已验收代码合并并推送到 `master`,服务器原来没有 `.venv`。部署脚本会在 `/opt/weread-board/WeReadBoard/.venv` 创建环境,安装 Flask 和 PyMySQL,确认阅读目标表存在,再让 systemd 重新用 `.venv/bin/python app.py` 启动服务。仅推送 `dev` 不会触发部署。 +## 正式看板登录认证 + +`auth.py` 注册全站请求拦截和登录路由。`/` 与所有 `/api/*` 数据接口都需要 `session["auth_user"]`;静态资源、`/login`、`/auth/login` 和首次初始化路由是唯一公开入口。页面请求未认证时重定向到登录页,API 返回 401,避免前端遮罩之外仍可直接读取数据。旧 `/api/v2/*` 路由同样由 Flask 全局拦截。 + +账号表 `weread_auth` 固定使用 `id=1` 保存单管理员的用户名和 Werkzeug 密码哈希。数据库无账号时,只有提交服务端 `WEREAD_AUTH_SETUP_TOKEN` 才能插入首个账号;例如提交用户名 `reader`、一次性口令正确、密码 `a-long-private-password`,数据库保存的是哈希而不是密码。插入成功后创建永久 Flask 会话,并清空当前进程中的初始化口令。后续登录从 MySQL 取哈希校验;用户名或密码错误统一返回“账号或密码不正确”。 + +会话签名密钥从 `WEREAD_SESSION_SECRET` 读取且必须稳定配置;缺失时服务对动态页面和 API 以 503 失败关闭。永久会话时长是固定 5 天,不因每次请求续期。Cookie 为 HttpOnly、SameSite=Lax,并默认 Secure;仅本机 HTTP 预览可以关闭 Secure。生产站点须使用 HTTPS。前端 JSON 写请求从页面 meta 标签读取 CSRF token 并放入 `X-CSRF-Token` 请求头,表单请求使用隐藏字段。 + +应用未内置登录尝试限流;公网部署应在可信反向代理上为 `POST /auth/login` 设置频率限制,且不要直接信任未校验的 `X-Forwarded-For`。 + ## 示例与真实接口的边界 概览图表使用固定样例;目标日期状态使用浏览器当前本地日期。笔记导出包含当前选中书目的划线和个人想法全集,不受内容类型筛选影响,不包含书签内容及公开点评。 diff --git a/docs/deploy-master.md b/docs/deploy-master.md index 37bf9a8..5c9ba77 100644 --- a/docs/deploy-master.md +++ b/docs/deploy-master.md @@ -1,6 +1,6 @@ # master 自动部署 -GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行阅读目标表迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。 +GitHub Actions workflow 位于 `.github/workflows/deploy.yml`。每次 `master` 分支 push 后,Actions 会通过 SSH 登录服务器,在部署目录执行根目录 `deploy.sh`,拉取最新 `master`、安装依赖、执行全部数据库迁移并重启服务。手动触发时也固定部署 `master`,不能选择其他分支发布。 ## GitHub 配置 @@ -32,7 +32,7 @@ git checkout master git pull --ff-only origin master python3 -m venv .venv # 仅 .venv 不存在时执行 .venv/bin/python -m pip install -r requirements.txt -# 读取 reading_api.MYSQL_CONFIG 后执行 migrations/001_reading_goals.sql +# 读取 reading_api.MYSQL_CONFIG 后按顺序执行 migrations/*.sql sudo systemctl restart "$SERVICE_NAME" ``` @@ -56,6 +56,9 @@ Environment=WEREAD_DB_USER=weread Environment=WEREAD_DB_PASSWORD=change-me Environment=WEREAD_DB_NAME=weread Environment=PORT=8770 +Environment=WEREAD_SESSION_SECRET=<服务器生成的随机密钥> +Environment=WEREAD_AUTH_SETUP_TOKEN=<仅首次初始化使用的随机口令> +Environment=WEREAD_SESSION_COOKIE_SECURE=1 ExecStart=/opt/weread-board/WeReadBoard/.venv/bin/python app.py Restart=always RestartSec=3 @@ -65,3 +68,7 @@ WantedBy=multi-user.target ``` 如果服务名或部署目录不同,在仓库 Variables 中调整 `SERVICE_NAME` 和 `DEPLOY_PATH`。 + +登录认证还需配置 `WEREAD_SESSION_SECRET` 和首次使用的 `WEREAD_AUTH_SETUP_TOKEN`。在服务器分别运行两次 `python3 -c 'import secrets; print(secrets.token_urlsafe(48))'` 生成不同的值。迁移自动创建 `weread_auth` 表;首次访问 `/login` 时输入初始化口令、管理员账号和至少 12 位密码。初始化成功后,从 systemd 配置中删除 `WEREAD_AUTH_SETUP_TOKEN` 并重启服务。数据库中的密码只保存哈希。 + +`WEREAD_SESSION_COOKIE_SECURE=1` 要求浏览器通过 HTTPS 访问。仅本机 HTTP 验收可临时设为 `0`,不要在公网 HTTP 下传输登录密码。 diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index e1c44ba..afe91c7 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -55,6 +55,7 @@ | F16 | 数据状态:加载、空、失败及重试;演示来源常驻 | 已验证(示例) | 待实现 | `web-demo/app.js:158` `renderDiscover` | | F17 | API Key 仅服务端使用;版本上报、升级中断、错误转换、分页 | 不属于本轮 Demo | 待实现 | 后续核对 `app.py` 和 `fetch_data.py`,复用现有服务端能力 | | F18 | 可选统计:阅读时段、作者/出版社/版权方、阅读听书比例、好友排行、勋章、年报 | 已验证(示例) | 待实现;按实际返回字段显示 | `web-demo/app.js:85` `renderOverview` | +| F19 | 单管理员登录保护所有页面/API;首次初始化需一次性口令;密码哈希存 MySQL;会话固定 5 天 | 不适用 | 已实现,真实 MySQL 初始化待部署验证 | `auth.py`、`migrations/002_site_auth.sql`、`templates/login.html` | ## 全部文档接口的落点 @@ -122,6 +123,6 @@ | ID | 功能与验收标准 | Demo 状态 | 真实接入状态 | 代码位置 | |---|---|---|---|---| -| F19 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 | +| F20 | 待填写;涉及新规则时追加 R 编号 | 待实现 | 待实现 | 实现后填写文件、函数和行号 | 后续开发顺序:核对实时 `/_list` 与公开书单成员能力 → 服务端鉴权及只读接口 → 替换 Demo 数据 → 校验分页与字段口径 → 正式目标持久化。每次只将已实际验收的范围标为完成。 diff --git a/docs/technical-guide.md b/docs/technical-guide.md index d7773b2..e4d3744 100644 --- a/docs/technical-guide.md +++ b/docs/technical-guide.md @@ -68,7 +68,7 @@ node --check web-demo/demo-data.js 沿用 `reading_api.py` 的 `WEREAD_DB_HOST`、`WEREAD_DB_PORT`、`WEREAD_DB_USER`、`WEREAD_DB_PASSWORD` 和 `WEREAD_DB_NAME` 配置,不新增依赖。服务仍使用当前个人账号的数据;多个浏览器连接同一服务时共享目标,没有新增多用户隔离。 -部署新代码前,在项目根目录执行一次迁移。下面的命令复用服务配置,只创建两张目标表,不修改已有业务表: +手动部署新代码时,在项目根目录按文件名顺序执行迁移。下面命令复用服务配置,幂等创建项目迁移定义的表: ```sh python3 - <<'PY' @@ -78,19 +78,40 @@ from reading_api import MYSQL_CONFIG with pymysql.connect(**MYSQL_CONFIG) as conn: with conn.cursor() as cur: - for sql in Path('migrations/001_reading_goals.sql').read_text().split(';'): - if sql.strip(): - cur.execute(sql) -print('阅读目标表已就绪') + for migration in sorted(Path('migrations').glob('*.sql')): + for sql in migration.read_text(encoding='utf-8').split(';'): + if sql.strip(): + cur.execute(sql) +print('数据库迁移已完成') PY PORT=8080 python3 app.py ``` 使用已安装 `requirements.txt` 的 Python 环境。若 8080 已有本项目实例,先停止旧实例再启动,使 Flask 加载新接口。迁移可重复执行;运行账户需要目标表的读写权限。目标表不参与 `fetch_data.py` 的采集覆盖。 +## 网站登录认证 + +正式页面和所有 `/api/*` 接口(包括 `/api/reading/*`、旧 `/api/v2/*` 与健康检查)都要求登录。未登录访问页面会跳转 `/login`;未登录直接请求 API 返回 401。CSS、JavaScript 等静态资源不包含个人数据,可公开加载。登录变更请求使用 CSRF token。 + +账号保存在 MySQL 的 `weread_auth` 表,密码以 Werkzeug 单向哈希存储。部署脚本会按文件名顺序执行 `migrations/*.sql`,其中 `002_site_auth.sql` 创建账号表。表中只允许一个管理员账号。 + +服务启动需要配置稳定的 `WEREAD_SESSION_SECRET`,用于签名会话;会话固定有效 5 天,Cookie 设置为 HttpOnly、SameSite=Lax,且 `WEREAD_SESSION_COOKIE_SECURE` 默认开启。生产环境必须通过 HTTPS 访问;仅本机 HTTP 预览时可设为 `WEREAD_SESSION_COOKIE_SECURE=0`。 + +首次部署还需在 systemd 环境中临时配置 `WEREAD_AUTH_SETUP_TOKEN`。可在服务器生成两个独立随机值: + +```sh +python3 -c 'import secrets; print(secrets.token_urlsafe(48))' +``` + +将一个结果配置为 `WEREAD_SESSION_SECRET`,另一个配置为 `WEREAD_AUTH_SETUP_TOKEN`,重启服务后访问 `/login`。数据库尚无账号时,页面要求一次性口令、管理员用户名和至少 12 位密码;成功后只将密码哈希写入 MySQL,并关闭当前进程的初始化入口。然后从 systemd 配置中移除 `WEREAD_AUTH_SETUP_TOKEN` 并再次重启。若数据库已有账号,初始化表单不会开放。 + +不要把上述值写入 Git、浏览器代码或公开聊天。初始化口令不能省略:没有它,任何公网访问者都可能抢先创建管理员账号。当前应用不提供密码找回入口;需由管理员在服务器侧重置数据库中的哈希。 + +应用本身未实现登录尝试限流。公网部署还应在 Nginx 或其他可信入口对 `POST /auth/login` 配置频率限制,不能信任客户端自行传入的转发 IP。 + ## GitHub 自动部署 -服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、执行阅读目标迁移,并重启 systemd 服务。 +服务器部署使用根目录的 `deploy.sh`。脚本会先拉取指定分支,再检测 Python 环境;若项目 `.venv` 不存在,会自动用系统 `python3` 或 `python` 创建 `.venv`,随后安装 `requirements.txt`、按顺序执行 `migrations/*.sql`,并重启 systemd 服务。 当前服务器配置可按下列默认值使用: @@ -150,6 +171,6 @@ node --check static/js/weapi.js ## master 自动部署 -`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行阅读目标表迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。 +`master` 分支 push 后由 `.github/workflows/deploy.yml` 触发部署。Actions 通过仓库 Secrets 登录服务器,在 `DEPLOY_PATH` 指定的目录先拉取 `origin/master`,再执行根目录 `deploy.sh`,完成加载可选 `.env`、安装 Python 依赖、执行全部数据库迁移和重启 systemd 服务。`dev` 和其他分支不会触发部署,部署脚本也会拒绝非 `master` 分支。详细配置见 [master 自动部署](deploy-master.md)。 本次数据库验收已在 8080 正式看板完成:页面新建目标后刷新仍可读取,编辑后再次刷新仍可读取,并已清理临时验收目标。读取目标列表未完成时,新建和编辑入口只显示目标页的加载或错误状态,不会打开保存表单。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7b5ee4c..efbe5af 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -18,6 +18,14 @@ ## 快速排查 +### 登录与会话 + +- 登录页返回 503:检查 systemd 是否配置 `WEREAD_SESSION_SECRET`,并确认数据库迁移已创建 `weread_auth` 表。 +- 首次设置提示初始化口令无效:确认 `WEREAD_AUTH_SETUP_TOKEN` 已在服务环境中配置;账号创建成功后应从服务配置移除该值。 +- 输入正确密码后仍回到登录页:公网必须通过 HTTPS 访问,检查 Cookie 的 `Secure` 标记与 `WEREAD_SESSION_COOKIE_SECURE` 设置;本机 HTTP 预览才设为 `0`。 +- 页面可打开但 API 返回 401:登录 Cookie 未随请求发送,先重新登录并确认页面与 API 使用同一主机名和协议。 +- 登录后写操作返回 CSRF 错误:刷新页面获取新的 token 后重试,不要跨域代理前端与 API。 + 页面出现“请求失败”时,按下面的顺序检查。 ### 检查监听端口 diff --git a/migrations/002_site_auth.sql b/migrations/002_site_auth.sql new file mode 100644 index 0000000..1db56d9 --- /dev/null +++ b/migrations/002_site_auth.sql @@ -0,0 +1,10 @@ +-- 单管理员账号;密码仅保存 Werkzeug 生成的单向哈希。 +CREATE TABLE IF NOT EXISTS weread_auth ( + id TINYINT UNSIGNED NOT NULL, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uq_weread_auth_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='阅间访问账号'; diff --git a/static/css/styles.css b/static/css/styles.css index 8e7063c..0050bff 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -91,3 +91,4 @@ .goal-card .cover-stack{height:62px} .stack-more{margin-left:8px;white-space:nowrap} } +.login-page{min-height:100vh;display:grid;place-items:center;padding:24px}.login-shell{width:min(100%,420px)}.login-logo{justify-content:center;margin-bottom:26px}.login-logo .logo-mark{border-radius:9px}.login-logo svg{width:24px;height:24px}.login-panel{padding:30px}.login-panel h1{font-size:23px;margin:0 0 8px}.login-panel>p{margin-bottom:23px}.login-form{display:grid;gap:16px;margin-top:22px}.login-form label{display:grid;gap:7px;font-size:12px;color:var(--text)}.login-form input{width:100%;min-height:42px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);padding:10px 12px}.login-form input:focus{border-color:var(--accent)}.login-form .primary{width:100%;min-height:42px;margin-top:4px}.login-error{margin-top:16px;padding:10px 12px;border:1px solid #e6b9b5;border-radius:7px;background:#fff5f4;color:#a8443c;font-size:12px;line-height:1.6}.login-foot{text-align:center;margin-top:20px}.top-actions form{margin:0}.logout-button{white-space:nowrap}@media(max-width:600px){.login-page{padding:18px}.login-panel{padding:24px}.top-actions{gap:9px}.logout-button{font-size:10px}} diff --git a/static/js/weapi.js b/static/js/weapi.js index 656ca01..510847e 100644 --- a/static/js/weapi.js +++ b/static/js/weapi.js @@ -21,7 +21,11 @@ }; const send = base => fetch(makeUrl(base).toString(), { method, - headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, + headers: { + Accept: 'application/json', + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) ? { 'X-CSRF-Token': global.document.querySelector('meta[name="csrf-token"]')?.content || '' } : {}), + }, ...(body ? { body: JSON.stringify(body) } : {}), }); let response = await send(BASE); diff --git a/templates/index.html b/templates/index.html index 4d1a578..03d8d3a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,7 +1,7 @@
- +