Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
644b106
fix: constant-time comparison for download tokens (security item 5)
Marrrrrrrrry Sep 10, 2026
014cd90
fix: single source for attachment headers + guard tests (security ite…
Marrrrrrrrry Sep 10, 2026
9d5fa57
fix: reject internal-network endpoints for storage config (security i…
Marrrrrrrrry Sep 10, 2026
3b5e25a
fix: JSON 404 for handler-raised 404s; negative-path test batch
Marrrrrrrrry Sep 10, 2026
b551b6a
fix: run container as non-root app user with compat volume chown (sec…
Marrrrrrrrry Sep 10, 2026
db1a39b
fix: compare download tokens as bytes (non-ASCII key caused 500)
Marrrrrrrrry Sep 10, 2026
c1f0abc
test: lock changed-only semantics for endpoint config saves
Marrrrrrrrry Sep 18, 2026
3addb41
fix: resolve-based SSRF check — deny IP shorthands, hex/decimal IPs, …
Marrrrrrrrry Sep 18, 2026
2182091
test: pin 404 handler dual-branch behavior (browser theme page vs API…
Marrrrrrrrry Sep 18, 2026
bc51a78
fix: write back normalized endpoint values; hostname dev-gate test
Marrrrrrrrry Sep 18, 2026
c4b5b26
test: header-injection negative paths for attachment disposition
Marrrrrrrrry Sep 18, 2026
258a19e
fix: S3 merge buffers parts to >=5MiB; missing object 404 upfront
Marrrrrrrrry Sep 18, 2026
3dafe21
test: S3 backend coverage via in-process moto server (13 cases)
Marrrrrrrrry Sep 18, 2026
dc56f27
fix: WebDAV missing object 404 upfront; connection errors map to 503
Marrrrrrrrry Sep 18, 2026
65b3575
test: WebDAV backend coverage via in-process WebDAV server (9 cases)
Marrrrrrrrry Sep 18, 2026
bd05cbc
fix: OneDrive missing object maps to 404 upfront; OpenDAL behavior pi…
Marrrrrrrrry Sep 18, 2026
c23b6d6
test: admin write-path coverage (batch/single delete, batch update, p…
Marrrrrrrrry Sep 18, 2026
12dba9d
test: admin read-path and view-preset CRUD coverage (22 cases)
Marrrrrrrrry Sep 18, 2026
0d524ee
refactor: move auth primitives to apps/base/auth, kill base->admin dep
Marrrrrrrrry Sep 18, 2026
b3f1d7f
refactor: split admin services god-module (ConfigService/LocalFile out)
Marrrrrrrrry Sep 18, 2026
f9e29dd
fix: size-less uploads no longer crash (upstream seek bug)
Marrrrrrrrry Sep 18, 2026
b1e24ef
chore: bump direct deps (patch/minor)
Marrrrrrrrry Sep 18, 2026
fc20567
ci: gradual mypy adoption via baseline ratchet
Marrrrrrrrry Sep 18, 2026
d0046c8
fix: migrate WebDAV auth off aiohttp.BasicAuth (deprecated in 4.0)
Marrrrrrrrry Sep 18, 2026
4fde3ef
chore: drop dead code (WebDAV _instance stub, unused LocalFileClass.w…
Marrrrrrrrry Sep 18, 2026
fa4f5f0
test: file-validation negative paths (13 cases)
Marrrrrrrrry Sep 18, 2026
a12c256
test: migration runner coverage (5 cases)
Marrrrrrrrry Sep 18, 2026
1640823
refactor: split core/storage.py into a package (per-backend modules)
Marrrrrrrrry Sep 18, 2026
22a3285
refactor: TypedDict for IPRateLimit records
Marrrrrrrrry Sep 18, 2026
915566d
chore: tighten mypy baseline after IPRateLimit TypedDict
Marrrrrrrrry Sep 18, 2026
1bfcdca
refactor: resolve all 26 baseline mypy errors; precise test assertions
Marrrrrrrrry Sep 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ jobs:
# --require-hashes so CI fails on lockfile drift, exactly like the
# Docker build does.
pip install --require-hashes -r requirements.lock.txt
pip install pytest pytest-asyncio httpx
pip install pytest pytest-asyncio httpx 'moto[s3,server]' mypy

- name: Ruff
run: pipx run ruff==0.16.6 check .

- name: Mypy ratchet (new type errors are rejected; fix and run
scripts/mypy_ratchet.py --regenerate to tighten the baseline)
run: python scripts/mypy_ratchet.py

- name: Verify lockfile matches requirements.txt
# Dependabot bumps requirements.txt but cannot regenerate the hashed
# lockfile; without this check a stale lockfile would silently keep
Expand Down
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,17 @@ COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023

# 安装系统安全更新 + Python 依赖
# 依赖从带哈希的锁定文件安装(--require-hashes),保证构建可复现、防供应链篡改。
# 清理 apt 缓存,降低镜像噪音与扫描面
# gosu 用于入口脚本的数据卷属主修正后降权;清理 apt 缓存,降低镜像噪音与扫描面
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends gosu \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir --require-hashes -r requirements.lock.txt \
&& pip cache purge || true

# 非 root 运行用户;数据卷属主由 docker-entrypoint.sh 按需修正(兼容存量 root 卷)
RUN useradd --system --uid 10001 --home-dir /app app

# 环境变量配置
ENV HOST="0.0.0.0" \
PORT=12345 \
Expand All @@ -77,6 +81,10 @@ ENV HOST="0.0.0.0" \

EXPOSE 12345

COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

# 生产环境启动命令
# FORWARDED_ALLOW_IPS 默认为空:仅信任直连 IP,避免任意客户端伪造 X-Forwarded-*。
# 若前面有反向代理,请显式设置为代理网段,例如 "10.0.0.0/8,172.16.0.0/12"。
Expand Down
159 changes: 159 additions & 0 deletions apps/admin/config_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""System config write path (ConfigService) + the KeyValue JSON lock.

keyvalue_write_lock lives here because ConfigService.update_config is its
primary consumer; FileService imports it (one-way, no cycle).
"""
import asyncio

from fastapi import HTTPException

from core.settings import (
ADMIN_SESSION_EXPIRE_MAX,
ADMIN_SESSION_EXPIRE_MIN,
settings,
)
from apps.base.config import refresh_settings
from core.security import (
INTERNAL_CONFIG_KEYS,
OUTBOUND_ENDPOINT_CONFIG_KEYS,
generate_jwt_secret,
validate_outbound_endpoint,
validate_outbound_hostname,
)
from apps.base.models import KeyValue
from core.utils import hash_password, is_password_hashed, validate_background_url

# KeyValue 里的 settings/activities/presets 都是整块 JSON 读-改-写;
# 进程内写锁串行化这三个写路径,避免并发管理操作互相覆盖(last-writer-wins)。
# 多进程部署下锁不跨进程——文档已锁定单 worker 部署。
keyvalue_write_lock = asyncio.Lock()


class ConfigService:
INT_FIELDS = {
"admin_session_expire",
"enable_chunk",
"error_count",
"error_minute",
"login_count",
"login_minute",
"max_save_seconds",
"onedrive_proxy",
"open_upload",
"port",
"s3_proxy",
"server_port",
"server_workers",
"show_admin_addr",
"storage_limit",
"upload_count",
"upload_minute",
"upload_size",
"webdav_proxy",
}
FLOAT_FIELDS = {"opacity"}

def get_config(self):
config = dict(settings.items())
config["admin_token"] = ""
for key in INTERNAL_CONFIG_KEYS:
config.pop(key, None)
return config

async def update_config(self, data: dict):
current_config = dict(settings.items())
next_config = dict(current_config)
update_data = {
key: value
for key, value in data.items()
if key in settings.default_config and key not in INTERNAL_CONFIG_KEYS
}

admin_token = update_data.get("admin_token")
admin_password_changed = False
if admin_token is None or admin_token == "":
update_data.pop("admin_token", None)
elif not is_password_hashed(admin_token):
update_data["admin_token"] = hash_password(admin_token)
admin_password_changed = True
else:
admin_password_changed = True

for key, value in update_data.items():
if value == "" and key in self.INT_FIELDS | self.FLOAT_FIELDS:
continue

try:
if key in self.INT_FIELDS:
next_config[key] = int(value)
elif key in self.FLOAT_FIELDS:
next_config[key] = float(value)
else:
next_config[key] = value
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"{key} 配置值格式错误")

try:
session_expire = int(str(next_config.get("admin_session_expire")))
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail="admin_session_expire 配置值格式错误",
)
if (
not ADMIN_SESSION_EXPIRE_MIN <= session_expire <= ADMIN_SESSION_EXPIRE_MAX
or session_expire % ADMIN_SESSION_EXPIRE_MIN != 0
):
raise HTTPException(
status_code=400,
detail="admin_session_expire 必须是 1 到 365 个整天",
)
next_config["admin_session_expire"] = session_expire

if int(next_config.get("storage_limit", 0)) < 0:
raise HTTPException(
status_code=400,
detail="storage_limit 不能小于 0",
)

# 只校验"发生变化"的值:升级前存入的旧格式 background(相对路径、含空格
# 或括号)在旧版本是合法的,若每次保存都重新校验,存量部署会连无关设置项
# 都保存不了(一律 400)。渲染侧仍然 html 转义,而任何修改都必须通过校验。
current_background = str(settings.background or "")
candidate_background = str(next_config.get("background") or "")
if candidate_background != current_background:
try:
validate_background_url(candidate_background)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))

# 只校验"发生变化"的值:企业内网 minio/webdav 是正当场景,存量部署历史
# 合法写入的内网 endpoint 若每次保存都重新校验,会连无关设置都保存不了
# (background 曾有同款回归,上游 #528 修复过——本处沿用同一语义)。
# s3_hostname 是裸主机名(存储层按 https://{hostname} 拼接),单独分档校验。
for endpoint_key in OUTBOUND_ENDPOINT_CONFIG_KEYS:
if endpoint_key not in next_config:
continue
candidate = str(next_config[endpoint_key] or "")
current = str(getattr(settings, endpoint_key, "") or "")
if candidate == current:
continue
validator = (
validate_outbound_hostname
if endpoint_key == "s3_hostname"
else validate_outbound_endpoint
)
try:
# 写回规范化值(validator 去除首尾空白):校验通过但入库脏值
# 会让存储层在连接期才报错,应在校验点归一。
next_config[endpoint_key] = validator(candidate)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))

if admin_password_changed:
next_config["jwt_secret"] = generate_jwt_secret()

async with keyvalue_write_lock:
await KeyValue.update_or_create(key="settings", defaults={"value": next_config})
await refresh_settings(force=True)

Loading
Loading