激光材料数据平台 1.3.0

公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 22:06:02 +08:00
co-authored by Claude Fable 5
commit edfb216561
49 changed files with 3922 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
import pytest
from fastapi import HTTPException
from app.images import MAX_IMAGES_PER_RECORD, inspect_image, sniff
from conftest import jpeg_bytes, png_bytes, record_payload
def upload(client, headers, data=None, name="shot.png", content_type="image/png", thumbnail=True):
files = {"file": (name, data if data is not None else png_bytes(), content_type)}
if thumbnail:
files["thumbnail"] = ("thumb.png", png_bytes(8, 6), "image/png")
return client.post("/api/uploads/images", headers=headers, files=files)
def test_headers_identify_supported_formats():
assert sniff(png_bytes(40, 25)) == ("image/png", 40, 25)
assert sniff(jpeg_bytes(640, 480)) == ("image/jpeg", 640, 480)
@pytest.mark.parametrize("blob", [
b'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
b"<!doctype html><h1>not an image</h1>",
b"GIF89a\x01\x00\x01\x00\x00\x00\x00", # GIF 不在白名单内
png_bytes()[:12], # 截断文件
b"\xff\xd8\xff" + b"\x00" * 40, # 伪造 JPEG 头但没有 SOF
])
def test_non_image_payloads_are_rejected(blob):
with pytest.raises(HTTPException) as error:
inspect_image(blob)
assert error.value.status_code in (400, 413, 415)
def test_oversized_payload_is_rejected():
with pytest.raises(HTTPException) as error:
inspect_image(png_bytes(), limit=10)
assert error.value.status_code == 413
def test_absurd_dimensions_are_rejected():
with pytest.raises(HTTPException) as error:
inspect_image(jpeg_bytes(30000, 30000))
assert error.value.status_code == 413
def test_upload_requires_authentication(client):
client.cookies.clear()
assert client.post("/api/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 401
def test_upload_requires_csrf_token(admin):
client, _ = admin
assert client.post("/api/uploads/images", files={"file": ("a.png", png_bytes(), "image/png")}).status_code == 403
def test_upload_returns_metadata_and_token(admin):
client, headers = admin
body = upload(client, headers, png_bytes(64, 48)).json()
assert body["ok"] and body["data"]["width"] == 64 and body["data"]["height"] == 48
assert body["data"]["content_type"] == "image/png" and body["data"]["token"]
def test_disguised_html_upload_is_rejected(admin):
client, headers = admin
response = upload(client, headers, b"<script>alert(1)</script>", name="evil.png")
assert response.status_code == 415
def test_pending_image_is_private_until_attached(admin):
client, headers = admin
image_id = upload(client, headers).json()["data"]["id"]
assert client.get(f"/api/images/{image_id}").status_code == 200
client.cookies.clear()
assert client.get(f"/api/images/{image_id}").status_code == 404
def test_attached_image_is_public_and_cacheable(admin):
client, headers = admin
token = upload(client, headers, png_bytes(50, 40)).json()["data"]["token"]
created = client.post("/api/records", headers=headers, json=record_payload("LAS-PUB-001", image_tokens=[token]))
assert created.status_code == 200, created.text
record_id = created.json()["data"]["id"]
images = client.get(f"/api/public/records/{record_id}").json()["data"]["images"]
assert len(images) == 1
client.cookies.clear()
response = client.get(images[0]["url"])
assert response.status_code == 200
assert response.headers["content-type"] == "image/png"
assert response.headers["x-content-type-options"] == "nosniff"
assert "immutable" in response.headers["cache-control"]
assert client.get(response.url, headers={"If-None-Match": response.headers["etag"]}).status_code == 304
def test_record_detail_never_leaks_binary_payload(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-BLOB-001", image_tokens=[token])).json()["data"]["id"]
data = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]
assert "data" not in data and "thumbnail" not in data
assert data["images"][0]["url"].startswith("/api/images/")
def test_more_than_six_images_are_refused(admin):
client, headers = admin
tokens = [upload(client, headers).json()["data"]["token"] for _ in range(MAX_IMAGES_PER_RECORD + 1)]
response = client.post("/api/records", headers=headers, json=record_payload("LAS-MANY-001", image_tokens=tokens))
assert response.status_code == 422
assert "6" in response.json()["message"]
def test_unknown_or_reused_token_is_refused(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
assert client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-001", image_tokens=[token])).status_code == 200
reused = client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-002", image_tokens=[token]))
assert reused.status_code == 404
assert client.post("/api/records", headers=headers, json=record_payload("LAS-TOK-003", image_tokens=["nope"])).status_code == 404
def test_editing_drops_images_left_out_of_the_keep_list(admin):
client, headers = admin
tokens = [upload(client, headers).json()["data"]["token"] for _ in range(2)]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-EDIT-001", image_tokens=tokens)).json()["data"]["id"]
images = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]
assert len(images) == 2
kept = images[1]["id"]
updated = client.put(f"/api/records/{record_id}", headers=headers, json=record_payload("LAS-EDIT-001", image_ids=[kept]))
assert updated.status_code == 200, updated.text
remaining = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]
assert [item["id"] for item in remaining] == [kept]
assert client.get(f"/api/images/{images[0]['id']}").status_code == 404
def test_images_of_another_record_cannot_be_claimed(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
first = client.post("/api/records", headers=headers, json=record_payload("LAS-OWN-001", image_tokens=[token])).json()["data"]["id"]
image_id = client.get(f"/api/records/{first}", headers=headers).json()["data"]["images"][0]["id"]
second = client.post("/api/records", headers=headers, json=record_payload("LAS-OWN-002")).json()["data"]["id"]
response = client.put(f"/api/records/{second}", headers=headers, json=record_payload("LAS-OWN-002", image_ids=[image_id]))
assert response.status_code == 404
def test_deleting_a_record_removes_its_images(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-DEL-001", image_tokens=[token])).json()["data"]["id"]
image_id = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]["id"]
assert client.delete(f"/api/records/{record_id}", headers=headers).status_code == 200
assert client.get(f"/api/images/{image_id}").status_code == 404
def test_expired_pending_images_are_purged(admin):
from datetime import datetime, timedelta
from app.db import SessionLocal
from app.models import RecordImage
client, headers = admin
stale_token = upload(client, headers).json()["data"]["token"]
with SessionLocal() as db:
stale = db.query(RecordImage).filter_by(token=stale_token).one()
stale.created_at = datetime.now().astimezone() - timedelta(hours=5)
db.commit()
assert upload(client, headers).status_code == 200 # 任何一次新上传都会顺手清理孤儿
with SessionLocal() as db: # 按 token 判断:SQLite 会复用被删除行的 rowid,按 id 判断会误判
assert db.query(RecordImage).filter_by(token=stale_token).count() == 0
def test_viewer_role_cannot_upload(admin):
client, headers = admin
created = client.post("/api/users", headers=headers, json={"username": "readonly1", "display_name": "只读", "password": "ViewerPass123", "role": "viewer"})
assert created.status_code in (200, 409)
client.cookies.clear()
client.post("/api/auth/login", data={"username": "readonly1", "password": "ViewerPass123"})
import hashlib
import hmac
from app.config import settings
from app.main import SESSION_COOKIE
csrf = hmac.new(settings.secret.encode(), client.cookies.get(SESSION_COOKIE).encode(), hashlib.sha256).hexdigest()
assert upload(client, {"X-CSRF-Token": csrf}).status_code == 403
def test_filename_injection_characters_are_stripped(admin):
client, headers = admin
files = {"file": ('x" onload="alert(1)".png', png_bytes(), "image/png")}
body = client.post("/api/uploads/images", headers=headers, files=files).json()
stored = body["data"]["original_name"]
assert '"' not in stored and "<" not in stored and ">" not in stored and "'" not in stored
def test_payload_appended_after_image_end_marker_is_discarded(admin):
client, headers = admin
smuggled = png_bytes() + b"<html><script>alert(1)</script></html>" * 20
body = client.post("/api/uploads/images", headers=headers, files={"file": ("ok.png", smuggled, "image/png")}).json()
assert body["data"]["size_bytes"] == len(png_bytes())
token = body["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-TRIM-001", image_tokens=[token])).json()["data"]["id"]
url = client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]["url"]
assert b"<script>" not in client.get(url).content
def test_webp_is_no_longer_accepted(admin):
"""WebP 的 RIFF 头几乎不校验内容,16 字节垃圾就能冒充一张图,索性不收。"""
import struct
client, headers = admin
fake = b"RIFF" + struct.pack("<I", 0) + b"WEBPVP8X" + b"\x00" * 20
assert client.post("/api/uploads/images", headers=headers, files={"file": ("a.webp", fake, "image/webp")}).status_code == 415
def test_update_without_image_ids_keeps_existing_images(admin):
"""省略 image_ids 必须是"不动图片",否则任何不了解该字段的旧页面或脚本都会静默删光图。"""
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-KEEP-001", image_tokens=[token])).json()["data"]["id"]
payload = record_payload("LAS-KEEP-001")
assert "image_ids" not in payload
assert client.put(f"/api/records/{record_id}", headers=headers, json=payload).status_code == 200
assert len(client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"]) == 1
def test_explicit_empty_image_ids_clears_images(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-CLEAR-001", image_tokens=[token])).json()["data"]["id"]
client.put(f"/api/records/{record_id}", headers=headers, json=record_payload("LAS-CLEAR-001", image_ids=[]))
assert client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"] == []
def test_upload_without_content_length_is_refused(admin):
"""没有 Content-Length 就无法预判体积,而 multipart 在鉴权之前就已落盘,必须直接拒绝。"""
client, headers = admin
response = client.post("/api/uploads/images", headers={**headers, "Content-Type": "multipart/form-data; boundary=B", "Transfer-Encoding": "chunked"}, content=iter([b"--B--\r\n"]))
assert response.status_code == 411
def test_detail_endpoints_do_not_load_image_binaries(admin):
"""图片二进制是 deferred 列:公开详情是匿名接口,一次请求把几 MB 图片读进内存会被用来打内存。"""
from sqlalchemy import event
from app.db import engine
client, headers = admin
token = upload(client, headers, png_bytes(120, 90)).json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-DEFER-001", image_tokens=[token])).json()["data"]["id"]
statements = []
listener = lambda conn, cursor, statement, *rest: statements.append(statement)
event.listen(engine, "before_cursor_execute", listener)
try:
client.get(f"/api/public/records/{record_id}")
finally:
event.remove(engine, "before_cursor_execute", listener)
selected = " ".join(statements).lower()
assert "record_images.data" not in selected and "record_images.thumbnail\n" not in selected
def test_public_detail_hides_original_filename(admin):
"""原始文件名可能带姓名、车间、项目代号,公开看板不返回。"""
client, headers = admin
token = upload(client, headers, name="张三-三车间-复检.png").json()["data"]["token"]
record_id = client.post("/api/records", headers=headers, json=record_payload("LAS-PRIV-001", image_tokens=[token])).json()["data"]["id"]
public_image = client.get(f"/api/public/records/{record_id}").json()["data"]["images"][0]
assert "original_name" not in public_image and "size_bytes" not in public_image
assert set(public_image) == {"id", "width", "height", "url", "thumb_url"}
assert "original_name" in client.get(f"/api/records/{record_id}", headers=headers).json()["data"]["images"][0]
def test_broken_thumbnail_does_not_fail_the_upload(admin):
"""缩略图只是加速手段,坏了就回落到原图,不能把整次上传拖垮。"""
client, headers = admin
files = {"file": ("ok.png", png_bytes(), "image/png"), "thumbnail": ("bad.png", b"not an image at all", "image/png")}
body = client.post("/api/uploads/images", headers=headers, files=files)
assert body.status_code == 200
assert body.json()["data"]["thumb_url"] == body.json()["data"]["url"]
def test_pending_image_can_be_discarded_to_free_its_slot(admin):
from app.db import SessionLocal
from app.models import RecordImage
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 200
with SessionLocal() as db:
assert db.query(RecordImage).filter_by(token=token).count() == 0
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 404
def test_attached_image_cannot_be_discarded_through_the_upload_endpoint(admin):
client, headers = admin
token = upload(client, headers).json()["data"]["token"]
client.post("/api/records", headers=headers, json=record_payload("LAS-DISC-001", image_tokens=[token]))
assert client.delete(f"/api/uploads/images/{token}", headers=headers).status_code == 404
def test_backup_size_column_holds_more_than_two_gigabytes():
"""图片入库后备份很容易超过 2 GiBPostgreSQL 的 integer 到那里就溢出了。"""
from sqlalchemy import BigInteger
from app.models import BackupRecord
assert isinstance(BackupRecord.__table__.c.size_bytes.type, BigInteger)
def test_unauthenticated_upload_never_buffers_the_request_body(client):
"""鉴权必须发生在 multipart 缓冲之前,否则未登录请求也能驱动服务器往磁盘写临时文件。"""
consumed = []
class Probe:
def __iter__(self):
consumed.append(True)
yield b'--B\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\n' + b"x" * 200_000 + b"\r\n--B--\r\n"
client.cookies.clear()
response = client.post("/api/uploads/images", headers={"Content-Type": "multipart/form-data; boundary=B", "Content-Length": "200200"}, content=Probe())
assert response.status_code == 401
assert not consumed, "未登录请求的 body 不应被读取"