Compare commits

...
12 Commits
Author SHA1 Message Date
earthjasonlin f27c263dd4 fix(storage): reduce presigned URL expiry from 7 days to 1 day 2026-07-18 08:25:13 +08:00
earthjasonlin d30fd59ade fix(storage): use presigned URLs for private S3 buckets (7-day expiry) 2026-07-18 08:24:40 +08:00
earthjasonlin 2dc68ff408 feat(cache): add 30-day browser cache for uploaded images (middleware + S3 CacheControl) 2026-07-18 08:23:13 +08:00
earthjasonlin 54d778971d fix(storage): allow S3 without public_url, auto-construct path-style URL from endpoint 2026-07-18 08:17:35 +08:00
earthjasonlin 4787fdbf22 refactor(config): consolidate into single config.json, storage backend selectable 2026-07-18 08:05:39 +08:00
earthjasonlin 3a931fe752 feat(storage): add S3 image storage support with file-based config and migration script 2026-07-18 08:03:18 +08:00
earthjasonlin ddeec36a01 fix(dashboard): exclude pending postcards from sent stat count 2026-07-15 19:40:42 +08:00
earthjasonlin 6636af9990 fix(dashboard): hide time for pending postcards in recent activity 2026-07-15 19:38:52 +08:00
earthjasonlin ad85b85e1e fix(dashboard+list): pending status sort first, stats count, badge/recipient/time display 2026-07-15 19:36:31 +08:00
earthjasonlin abec829117 fix(i18n): repair broken pc_form.country_from key in postcard form template 2026-07-15 19:29:27 +08:00
earthjasonlin 0a8f8c4904 fix(i18n): rename country labels to '寄出国/到达国' to avoid ambiguity 2026-07-15 19:27:26 +08:00
earthjasonlin 2fe3ea5249 fix(postcard): reorder country fields to from→to on both create forms 2026-07-15 19:24:02 +08:00
17 changed files with 290 additions and 65 deletions

No files matched your search

+5 -1
View File
@@ -6,4 +6,8 @@ uploads/*
*.db
.env
server.log
scripts/
s3_config.json
config.json
scripts/fix_*.py
scripts/migrate_*.py
scripts/test_*.py
+1 -1
View File
@@ -1,5 +1,5 @@
@echo off
cd /d "%~dp0"
call .venv\Scripts\activate.bat
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
@rem python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
pause
+2 -2
View File
@@ -123,8 +123,8 @@
"pc_form.number_hint": "Format: AB-1234567",
"pc_form.status": "Status",
"pc_form.recipient": "Recipient",
"pc_form.country": "Country",
"pc_form.country_from": "From",
"pc_form.country_from": "From country",
"pc_form.country_to": "To country",
"pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender",
+2 -2
View File
@@ -123,8 +123,8 @@
"pc_form.number_hint": "格式:AB-1234567",
"pc_form.status": "状态",
"pc_form.recipient": "收件人",
"pc_form.country": "国家/地区",
"pc_form.country_from": "寄出地",
"pc_form.country_from": "寄出国",
"pc_form.country_to": "到达国",
"pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人",
+14 -1
View File
@@ -1,7 +1,8 @@
from pathlib import Path
from fastapi import FastAPI
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import UPLOAD_DIR
from app.database import init_db
@@ -13,6 +14,18 @@ UPLOAD_DIR.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
# Browser cache: 30 days for uploaded images (they never change)
class CacheControlMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request)
if request.url.path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
return response
app.add_middleware(CacheControlMiddleware)
app.include_router(web.router)
app.include_router(api.router)
+7 -6
View File
@@ -13,7 +13,7 @@ from app.models import (
PERM_POSTCARDS_READ, PERM_POSTCARDS_WRITE,
PERM_IMAGES_UPLOAD,
)
from app.config import UPLOAD_DIR
# config removed: image storage now via app.storage
router = APIRouter(prefix="/api/v1", tags=["api"])
@@ -380,15 +380,16 @@ async def upload_image(
ak: ApiKey = Depends(require_scopes(PERM_IMAGES_UPLOAD)),
):
pc = _postcard_or_404(ak.user, profile_id, postcard_id, db)
from app.storage import get_storage
storage = get_storage()
ext = Path(file.filename).suffix if file.filename else ".jpg"
save_name = f"pc_{pc.id}_{side}{ext}"
save_path = UPLOAD_DIR / save_name
key = f"pc_{pc.id}_{side}{ext}"
content = await file.read()
save_path.write_bytes(content)
storage.put(key, content)
if side == "front":
pc.image_front = f"/uploads/{save_name}"
pc.image_front = key
else:
pc.image_back = f"/uploads/{save_name}"
pc.image_back = key
db.commit()
db.refresh(pc)
return pc
+14 -9
View File
@@ -16,7 +16,7 @@ from app.auth import (
redirect_if_not_logged_in,
verify_password,
)
from app.config import SECRET_KEY, SESSION_COOKIE_NAME, UPLOAD_DIR
from app.config import SECRET_KEY, SESSION_COOKIE_NAME
from app.database import get_db
from random import choice
from string import ascii_letters, digits
@@ -86,6 +86,10 @@ def _translate_filter(key: str, lang: str = "zh") -> str:
templates.env.filters["t"] = _translate_filter
# image URL resolver: bare key → full URL (local /uploads/... or S3 public URL)
from app.storage import resolve_url as _resolve_image_url
templates.env.filters["image_url"] = lambda v: _resolve_image_url(v) if v else ""
from app.i18n import get_languages as _get_languages
templates.env.globals["available_languages"] = _get_languages()
@@ -188,7 +192,7 @@ def api_public_cards(
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": c.image_front,
"image_front": _resolve_image_url(c.image_front),
"country_from": c.country_from,
"country_to": c.country_to,
"status": c.status,
@@ -230,7 +234,7 @@ def api_public_cards_user(
"page": page,
"has_more": start + limit < total,
"cards": [{
"image_front": c.image_front,
"image_front": _resolve_image_url(c.image_front),
"country_from": c.country_from,
"country_to": c.country_to,
} for c in batch],
@@ -707,8 +711,8 @@ def postcard_list(
# collect existing countries for dropdown
all_countries = sorted({pc.country_to for pc in db.query(Postcard.country_to).filter(Postcard.profile_id == profile_id, Postcard.country_to.isnot(None)).all()})
# status sort order: sent=0, delivered=1, received=2
_status_order = {"sent": 0, "delivered": 1, "received": 2}
# status sort order: pending=0, sent=1, delivered=2, received=3
_status_order = {"pending": 0, "sent": 1, "delivered": 2, "received": 3}
postcards.sort(key=lambda pc: (_status_order.get(pc.status, 9), -(pc.send_time or pc.arrival_time or pc.receive_time or pc.created_at).timestamp()))
# split into sent and received
@@ -959,12 +963,13 @@ async def upload_image(
)
if not pc:
return _redirect("/profiles")
from app.storage import get_storage
storage = get_storage()
ext = Path(file.filename or "upload.jpg").suffix or ".jpg"
filename = f"{uuid4().hex}{ext}"
dest = UPLOAD_DIR / filename
key = f"{uuid4().hex}{ext}"
content = await file.read()
dest.write_bytes(content)
setattr(pc, f"image_{side}", f"/uploads/{filename}")
storage.put(key, content)
setattr(pc, f"image_{side}", key)
db.commit()
return _redirect(f"/postcards/{postcard_id}")
+15 -1
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, model_validator
# ---------- Profile ----------
@@ -23,6 +23,13 @@ class ProfileOut(BaseModel):
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _resolve_image_urls(self) -> "PostcardOut":
from app.storage import resolve_url
self.image_front = resolve_url(self.image_front) or None
self.image_back = resolve_url(self.image_back) or None
return self
# ---------- Postcard ----------
@@ -68,3 +75,10 @@ class PostcardOut(BaseModel):
updated_at: datetime
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _resolve_image_urls(self) -> "PostcardOut":
from app.storage import resolve_url
self.image_front = resolve_url(self.image_front) or None
self.image_back = resolve_url(self.image_back) or None
return self
+196
View File
@@ -0,0 +1,196 @@
"""Image storage abstraction: local filesystem or S3-compatible object storage.
All system configuration lives in ``config.json`` in the project root::
{
"storage": {
"backend": "local",
"s3": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-mailova-bucket",
"region": "auto",
"access_key": "AKIA...",
"secret_key": "...",
"public_url": "https://cdn.example.com/mailova"
}
}
}
``storage.backend``: ``"local"`` (default) or ``"s3"``.
URL scheme
~~~~~~~~~~
Database stores **bare filenames** (``xxx.jpeg``) regardless of backend.
``resolve_url(key)`` converts a bare key to a full URL:
- S3 → ``<public_url>/xxx.jpeg``
- local → ``/uploads/xxx.jpeg``
Legacy records that still contain ``/uploads/xxx.jpeg`` are auto-stripped
by ``resolve_url``.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Protocol
from app.config import UPLOAD_DIR
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
class StorageBackend(Protocol):
def put(self, key: str, data: bytes, content_type: str = "image/jpeg") -> str:
"""Store data and return the bare key."""
...
def delete(self, key: str) -> None: ...
def exists(self, key: str) -> bool: ...
def resolve_url(self, key: str) -> str:
"""Convert a bare key (or legacy /uploads/xxx path) to a full URL."""
...
# ── Local Storage ──────────────────────────────────────────────────
class LocalStorage:
def __init__(self) -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def put(self, key: str, data: bytes, content_type: str = "") -> str:
(UPLOAD_DIR / key).write_bytes(data)
return key
def delete(self, key: str) -> None:
p = UPLOAD_DIR / key
if p.exists():
p.unlink()
def exists(self, key: str) -> bool:
return (UPLOAD_DIR / key).exists()
def resolve_url(self, key: str) -> str:
return f"/uploads/{_strip_prefix(key)}"
# ── S3 Storage ─────────────────────────────────────────────────────
class S3Storage:
def __init__(
self,
endpoint_url: str,
bucket: str,
access_key: str,
secret_key: str,
region: str = "auto",
public_url: str = "",
) -> None:
import boto3
self.bucket = bucket
self.public_url = public_url.rstrip("/")
self._client = boto3.client(
"s3",
endpoint_url=endpoint_url or None,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region or "auto",
)
def put(self, key: str, data: bytes, content_type: str = "image/jpeg") -> str:
self._client.put_object(
Bucket=self.bucket, Key=key, Body=data, ContentType=content_type,
CacheControl="public, max-age=2592000, immutable",
)
return key
def delete(self, key: str) -> None:
self._client.delete_object(Bucket=self.bucket, Key=key)
def exists(self, key: str) -> bool:
try:
self._client.head_object(Bucket=self.bucket, Key=key)
return True
except Exception:
return False
def resolve_url(self, key: str) -> str:
k = _strip_prefix(key)
if self.public_url:
return f"{self.public_url}/{k}"
# private bucket: generate presigned URL (7 days expiry)
return self._client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": k},
ExpiresIn=86400,
)
# ── Helpers ────────────────────────────────────────────────────────
def _strip_prefix(key: str) -> str:
"""Strip ``/uploads/`` prefix from legacy DB values."""
if key.startswith("/uploads/"):
return key[len("/uploads/"):]
return key
# ── Config loading ─────────────────────────────────────────────────
def load_config() -> dict:
"""Load config.json. Returns empty dict if missing."""
if not CONFIG_PATH.exists():
return {}
try:
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
# ── Singleton accessor ─────────────────────────────────────────────
_backend: StorageBackend | None = None
def get_storage() -> StorageBackend:
"""Get the active storage backend based on config.json."""
global _backend
if _backend is not None:
return _backend
cfg = load_config()
backend = cfg.get("storage", {}).get("backend", "local")
if backend == "s3":
s3 = cfg.get("storage", {}).get("s3", {})
if not s3.get("bucket"):
# fallback to local if S3 is misconfigured
_backend = LocalStorage()
else:
_backend = S3Storage(
endpoint_url=s3.get("endpoint", ""),
bucket=s3["bucket"],
access_key=s3.get("access_key", ""),
secret_key=s3.get("secret_key", ""),
region=s3.get("region", "auto"),
public_url=s3.get("public_url", ""),
)
else:
_backend = LocalStorage()
return _backend
def resolve_url(key: str | None) -> str:
"""Convenience: resolve a possibly-None image key to a URL (or empty)."""
if not key:
return ""
return get_storage().resolve_url(key)
def reset_storage() -> None:
"""Force re-detection on next get_storage() call."""
global _backend
_backend = None
+3 -3
View File
@@ -108,14 +108,14 @@ new Chart(document.getElementById('chart-received'), {
<a href="/postcards/{{ pc.id }}" class="recent-item">
<div class="recent-img">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" loading="lazy">
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
{% else %}
<span>📷</span>
{% endif %}
</div>
<div class="recent-info">
<strong>{{ pc.card_number }}</strong>
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span>
<span class="badge badge-{{ pc.status }}">{{ {'pending':'待寄出','sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) if user.language=='zh' else {'pending':'Pending','sent':'Sent','delivered':'Delivered','received':'Received'}.get(pc.status) }}</span>
</div>
<div style="margin-left:auto;text-align:right;display:flex;gap:1rem;align-items:center">
<div>
@@ -125,7 +125,7 @@ new Chart(document.getElementById('chart-received'), {
{% endif %}
</div>
<div class="recent-meta" style="margin-left:0">
{% if pc.status in ('sent','delivered') %}
{% if pc.status in ('pending','sent','delivered') %}
<span>→ {{ pc.recipient_name or '-' }}</span>
{% else %}
<span>← {{ pc.sender_name or '-' }}</span>
+2 -2
View File
@@ -33,8 +33,8 @@
{% if visible|length > 0 or has_more %}
<div class="showcase-grid" id="showcase-grid">
{% for pc in visible %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
+2 -2
View File
@@ -28,7 +28,7 @@
<div class="image-block">
<h3>{{ 'pc_detail.front'|t(user.language) }}</h3>
{% if postcard.image_front %}
<img src="{{ postcard.image_front }}" alt="" class="detail-img">
<img src="{{ postcard.image_front|image_url }}" alt="" class="detail-img">
{% else %}
<div class="detail-img placeholder">{{ 'pc_detail.no_front'|t(user.language) }}</div>
{% endif %}
@@ -40,7 +40,7 @@
<div class="image-block">
<h3>{{ 'pc_detail.back'|t(user.language) }}</h3>
{% if postcard.image_back %}
<img src="{{ postcard.image_back }}" alt="" class="detail-img">
<img src="{{ postcard.image_back|image_url }}" alt="" class="detail-img">
{% else %}
<div class="detail-img placeholder">{{ 'pc_detail.no_back'|t(user.language) }}</div>
{% endif %}
+4 -4
View File
@@ -26,12 +26,12 @@
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}">
<label>{{ 'pc_form.country'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else (profile.country if profile else '') }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else '' }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country_to'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else (profile.country if profile else '') }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
{% else %}
{# ── 寄出表单 ── #}
{% if is_new %}
@@ -64,7 +64,7 @@
<label>{{ 'pc_form.country_from'|t(user.language) }}</label>
<input type="text" name="country_from" value="{{ postcard.country_from if is_edit and postcard.country_from else (profile.country if profile else '') }}" maxlength="2" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
<label>{{ 'pc_form.country'|t(user.language) }}</label>
<label>{{ 'pc_form.country_to'|t(user.language) }}</label>
<input type="text" name="country_to" value="{{ postcard.country_to if is_edit and postcard.country_to else '' }}" maxlength="2" placeholder="JP" style="width:80px;text-transform:uppercase" oninput="this.value=this.value.toUpperCase()">
{% endif %}
+2 -2
View File
@@ -36,7 +36,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" loading="lazy">
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
{% else %}
<div class="thumb placeholder">📷</div>
{% endif %}
@@ -78,7 +78,7 @@
<a href="/postcards/{{ pc.id }}" class="postcard-row">
<div class="postcard-row-img">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" loading="lazy">
<img src="{{ pc.image_front|image_url }}" alt="" loading="lazy">
{% else %}
<div class="thumb placeholder">📷</div>
{% endif %}
+8 -8
View File
@@ -45,8 +45,8 @@
{% if mode == 'flat' %}
<div class="showcase-grid" id="grid-sent">
{% for pc in sent_visible %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
@@ -70,8 +70,8 @@
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid">
{% for pc in g_sent %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
@@ -96,8 +96,8 @@
{% if mode == 'flat' %}
<div class="showcase-grid" id="grid-received">
{% for pc in received_visible %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
@@ -121,8 +121,8 @@
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid">
{% for pc in g_received %}
<div class="showcase-card" data-front="{{ pc.image_front }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card" data-front="{{ pc.image_front|image_url }}" onclick="openLightbox(this)">
<img src="{{ pc.image_front|image_url }}" alt="" class="showcase-card-img" loading="lazy">
<div class="showcase-card-body">
<div class="showcase-card-meta">
{% if pc.country_from and pc.country_to %}<span>{{ pc.country_from|flag }} {{ pc.country_from }} → {{ pc.country_to|flag }} {{ pc.country_to }}</span>
+13
View File
@@ -0,0 +1,13 @@
{
"storage": {
"backend": "local",
"s3": {
"endpoint": "https://s3.amazonaws.com",
"bucket": "my-mailova-bucket",
"region": "auto",
"access_key": "AKIA...",
"secret_key": "...",
"public_url": "https://cdn.example.com/mailova"
}
}
}
-21
View File
@@ -1,21 +0,0 @@
"""One-time export: convert i18n.py translations to JSON files."""
import json
from pathlib import Path
from app.i18n import _translations
locales_dir = Path(__file__).resolve().parent / "app" / "locales"
locales_dir.mkdir(exist_ok=True)
langs: dict[str, dict[str, str]] = {}
for key, values in _translations.items():
for lang, text in values.items():
langs.setdefault(lang, {})[key] = text
for lang, data in sorted(langs.items()):
out = locales_dir / f"{lang}.json"
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"Exported {len(data)} keys -> {out.name}")
print("Done!")