From 043c87a8dce3b46a79370885f93391fbd2b6fb56 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 11:12:26 -0400 Subject: [PATCH] Each account may store 5 GB of attachments; admins have no limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2939 §4. max_attachment_mb capped one file, so any account could fill the volume. The new Settings → Attachments → storage_quota_gb (default 5, 0 for no limit) caps an account's total. That total is every attachment on every note the account owns, trash included, since trashed files stay on disk until emptied. Admins are exempt. storage.upload_refusal is now the one check every upload makes: per file, then per account. It is used by: - the web upload route; - the sync PUT, which judges the declared Content-Length before reading the bytes; - the importer, which learns the room left up front and refuses the whole archive if its attachments don't fit (nothing is committed). Over the limit is answered 507 Insufficient Storage, not 413. The core treats a 4xx as a permanent refusal it never retries, and a 5xx as worth another try. So a file refused for want of room syncs by itself once space is freed. The cost is that an over-limit device re-sends that file each cycle until then. GET /api/auth/storage returns used and limit, and the Account page shows it as a Storage row ("1.2 GB of 5 GB used"). docs/public-hosting.md drops the quota gap and gains a section on the limit. Its Android paragraph still said a public http:// address was only warned about; since 1dd6fc1 it is refused, and the paragraph now says so. Co-Authored-By: Claude Opus 5.5 --- docs/public-hosting.md | 21 ++++++-- frontend/src/adapters/local.ts | 1 + frontend/src/adapters/repo.ts | 7 +++ frontend/src/adapters/rest.ts | 2 + frontend/src/views/AccountView.vue | 34 +++++++++++++ src/inkwell/auth.py | 15 ++++++ src/inkwell/notes/__init__.py | 14 ++++-- src/inkwell/notes/import_export.py | 11 ++++ src/inkwell/settings.py | 11 ++++ src/inkwell/storage.py | 78 +++++++++++++++++++++++++++++ src/inkwell/sync.py | 12 +++-- tests/test_integration.py | 80 ++++++++++++++++++++++++++++++ 12 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 src/inkwell/storage.py diff --git a/docs/public-hosting.md b/docs/public-hosting.md index 643f7d8..3161c0c 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -143,6 +143,17 @@ at the same speed, whether or not an address has an account, and `reset_emails_per_account` (Settings → Security) caps how many reset emails one address can be sent. +## Storage per account + +Each account may store up to `storage_quota_gb` of attachments (Settings → +Attachments, 5 GB by default; 0 means no limit). Admins have no limit. Trashed notes +count until the trash is emptied, because their files are still on disk. An upload +past the limit is refused with 507 Insufficient Storage, and a phone or desktop +retries that file on each sync, so it goes through once there is room. The Account +page shows how much an account uses. + +`max_attachment_mb` still caps any single file. + ## What it does not do Know these before you decide who gets an account. @@ -154,8 +165,6 @@ Know these before you decide who gets an account. - **An admin who forgets their own password**, with email off and no other admin, still needs a hand on the database. - **No second factor.** A password is the whole of it. -- **No per-user storage quota.** Any account can upload attachments until the volume - is full. `max_attachment_mb` caps a single file, not a total. - **No audit TABLE.** Credential events — sign-ins, failures, throttle trips, new accounts, device tokens issued — are written to the application log and readable with `docker compose logs app`, which is enough to see whether anyone is knocking. @@ -170,9 +179,11 @@ know. They are the reason not to hand out open registration to strangers. The app allows plain HTTP so a self-hosted server on a LAN is usable at all — Android blocks cleartext by default from API 28, and `http://192.168.1.10:8000` is exactly the case Inkwell is built for. Over the public internet, link the phone to the -**HTTPS** hostname. The sync screen shows a warning before any credential field -whenever the address it probed was `http://`; on a public network that warning means -what it says. +**HTTPS** hostname. The phone and desktop apps refuse a plain `http://` address that +isn't on a private network, before any password or token is sent. Allowed: private +and Tailscale IPs, single-label names like `nas`, and LAN names such as `.local` or +`.home.arpa`. A device already linked to a public `http://` address stops syncing +and says why. The APK the server hands out is signed with the project release key, and the in-app updater installs over the existing app only because the signature matches. A build diff --git a/frontend/src/adapters/local.ts b/frontend/src/adapters/local.ts index 218317f..78eff9d 100644 --- a/frontend/src/adapters/local.ts +++ b/frontend/src/adapters/local.ts @@ -31,6 +31,7 @@ export const local: Repo = { logout: () => Promise.resolve(), changePassword: () => Promise.reject(new Error(NEEDS_SERVER)), signOutElsewhere: () => Promise.reject(new Error(NEEDS_SERVER)), + storage: () => Promise.reject(new Error(NEEDS_SERVER)), }, devices: { diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index f84349d..346b0fa 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -110,6 +110,13 @@ export interface AuthRepo { changePassword(current: string, next: string): Promise; /** The same sign-out as `changePassword`, without changing the password. */ signOutElsewhere(): Promise; + /** Attachment storage this account uses, and its limit (null when it has none). */ + storage(): Promise; +} + +export interface StorageUse { + used_bytes: number; + limit_bytes: number | null; } export interface SignedOutElsewhere { diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 5f3f864..62853dd 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -23,6 +23,7 @@ import type { Repo, ServerSetting, SignedOutElsewhere, + StorageUse, } from "./repo"; // Render a board query to the GET /api/notes query string. Mirrors the param @@ -62,6 +63,7 @@ export const rest: Repo = { changePassword: (current, next) => api.post("/api/auth/password", { current_password: current, new_password: next }), signOutElsewhere: () => api.post("/api/auth/sign-out-elsewhere"), + storage: () => api.get("/api/auth/storage"), }, devices: { diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index 307a144..3fb4160 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -11,6 +11,8 @@ import Icon from "../components/Icon.vue"; import PageHeader from "../components/PageHeader.vue"; import { errorMessage } from "../api/errors"; import { formatDateTime } from "../notes/datetime"; +import { repo } from "../adapters"; +import type { StorageUse } from "../adapters/repo"; // The signed-in person's own account (not admin): the native clients linked to it // (the desktop and Android apps sync with a device bearer token issued here), its @@ -37,6 +39,25 @@ async function load() { } catch { error.value = "Couldn't load your linked devices."; } + // Separately, so a storage read that fails doesn't read as a devices failure. + try { + storage.value = await repo.auth.storage(); + } catch { + storage.value = null; + } +} + +// Attachment storage (#2939 §4): how much this account uses, against its limit. +const storage = ref(null); + +function gigabytes(bytes: number): string { + const gb = bytes / 1024 ** 3; + return `${gb < 10 ? gb.toFixed(1) : gb.toFixed(0)} GB`; +} + +function storageLine(use: StorageUse): string { + const used = gigabytes(use.used_bytes); + return use.limit_bytes === null ? `${used} used` : `${used} of ${gigabytes(use.limit_bytes)} used`; } async function link() { @@ -213,6 +234,19 @@ onMounted(() => { + +

Password

Changing it signs you out everywhere else.

diff --git a/src/inkwell/auth.py b/src/inkwell/auth.py index eb1ffa0..dcff987 100644 --- a/src/inkwell/auth.py +++ b/src/inkwell/auth.py @@ -26,6 +26,7 @@ from .ratelimit import ( ) from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password from .settings import get_setting, mail_configured, set_settings +from .storage import storage_limit, storage_used bp = Blueprint("auth", __name__, url_prefix="/api/auth") @@ -360,6 +361,20 @@ async def me(): return jsonify(_serialize_user(user)) +@bp.get("/storage") +@login_required +async def storage(): + """How much attachment storage this account uses, and its limit (None when it + has none). For the Account page (#2939 §4).""" + async with session_scope() as db: + return jsonify( + { + "used_bytes": await storage_used(db, g.user_id), + "limit_bytes": await storage_limit(db, g.user_id), + } + ) + + # The one answer to every forgot-password request that gets as far as sending. FORGOT_SENT = "If an account uses that address, a reset link is on its way. It works for an hour." diff --git a/src/inkwell/notes/__init__.py b/src/inkwell/notes/__init__.py index f49fb15..9248bc9 100644 --- a/src/inkwell/notes/__init__.py +++ b/src/inkwell/notes/__init__.py @@ -35,7 +35,7 @@ from ..note_state import OWN_FIELDS, archived_for, join_state, pinned_for, posit from .checklist import append_item, parse_items, set_item_checked from ..responses import json_error, not_found, parse_uuid from ..retention import purge_note -from ..settings import get_setting +from ..storage import OVER_LIMIT_STATUS, storage_room, upload_refusal from ..unfurl_queue import schedule as schedule_unfurls from ._bp import bp from .body import write_body @@ -61,6 +61,7 @@ from .import_export import ( IMPORT_MAX_ENTRIES, _ImportBudget, _ImportTooLarge, + _OverStorageLimit, _create_imported_note, _keep_spec, _native_spec, @@ -329,6 +330,7 @@ async def import_notes(): imported = 0 skipped = 0 async with session_scope() as db: + budget.storage_room = await storage_room(db, g.user_id) pos = await top_position(db, g.user_id) for spec in specs: if await _create_imported_note(db, g.user_id, spec, zf, pos + 1, budget): @@ -339,6 +341,11 @@ async def import_notes(): await db.commit() except _ImportTooLarge: return json_error("that archive is too large to import", 413) + except _OverStorageLimit: + return json_error( + "that archive's attachments would take this account past its storage limit; nothing was imported", + OVER_LIMIT_STATUS, + ) return jsonify({"source": source, "imported": imported, "skipped": skipped}), 201 @@ -635,9 +642,8 @@ async def upload_attachment(note_id: str): if existing is not None: return json_error("attachment id already in use", 409) raw = upload.stream.read() - max_mb = int(await get_setting(db, "max_attachment_mb")) - if len(raw) > max_mb * 1024 * 1024: - return json_error(f"file is too large (max {max_mb} MB)", 413) + if refusal := await upload_refusal(db, g.user_id, len(raw)): + return json_error(*refusal) # Any file type is allowed — images render inline, everything else downloads. store_attachment(db, note, raw, upload.filename, upload.content_type, att_id) await db.commit() diff --git a/src/inkwell/notes/import_export.py b/src/inkwell/notes/import_export.py index 9b1d704..938bbd1 100644 --- a/src/inkwell/notes/import_export.py +++ b/src/inkwell/notes/import_export.py @@ -164,6 +164,10 @@ class _ImportTooLarge(Exception): """An import zip decompressed past the byte budget (a zip bomb, or just too big).""" +class _OverStorageLimit(Exception): + """The import's attachments would take the account past its storage limit.""" + + class _ImportBudget: """Caps DECOMPRESSED bytes pulled from an import zip — per entry and cumulatively. @@ -174,6 +178,9 @@ class _ImportBudget: def __init__(self) -> None: self.remaining = IMPORT_MAX_TOTAL_BYTES + # Attachment bytes the account may still store (`storage.storage_room`), or + # None for no limit. Set by the route, which is async and can ask. + self.storage_room: int | None = None def read(self, zf: zipfile.ZipFile, name: str) -> bytes: cap = min(IMPORT_MAX_ENTRY_BYTES, self.remaining) @@ -225,6 +232,10 @@ def _import_attachment(db, note: Note, zf: zipfile.ZipFile, att: dict, budget: _ raw = budget.read(zf, zpath) except KeyError: return False + if budget.storage_room is not None: + if len(raw) > budget.storage_room: + raise _OverStorageLimit() + budget.storage_room -= len(raw) store_attachment(db, note, raw, posixpath.basename(zpath), att.get("mime")) return True diff --git a/src/inkwell/settings.py b/src/inkwell/settings.py index 202886d..899cbc9 100644 --- a/src/inkwell/settings.py +++ b/src/inkwell/settings.py @@ -85,6 +85,17 @@ REGISTRY: list[SettingDef] = [ "Largest single file that can be attached to a note. Capped by the server body limit.", "Attachments", ), + SettingDef( + "storage_quota_gb", + "int", + 5, + "Storage per account (GB)", + "Total attachment storage one account may use, trash included. Admins have no " + "limit. Set to 0 for no limit at all.", + "Attachments", + minimum=0, + maximum=100000, + ), SettingDef( "enable_url_unfurl", "bool", diff --git a/src/inkwell/storage.py b/src/inkwell/storage.py new file mode 100644 index 0000000..350d7de --- /dev/null +++ b/src/inkwell/storage.py @@ -0,0 +1,78 @@ +"""How much attachment storage an account may use (#2939 §4). + +`max_attachment_mb` caps one file; this caps an account's total, so one account +can't fill the volume. The limit is Settings → Attachments → `storage_quota_gb`; +0 turns it off. Admins have no limit, because the instance is theirs to fill. + +What counts is every attachment on every note the account owns, trashed notes +included: their files stay on disk until the trash is emptied. + +An upload that would go over the limit is answered 507 Insufficient Storage, not +413. The native clients treat a 4xx as permanent and never retry the file +(`client::upload_attachment` in the core), and a 5xx as worth another try. Over the +limit is neither: freeing space should let the file through on the next sync, with +nothing for the person to redo. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import func, select + +from .models.note import Note +from .models.note_attachment import NoteAttachment +from .models.user import User +from .settings import get_setting + +GB = 1024**3 +OVER_LIMIT_STATUS = 507 + + +async def storage_used(db, user_id: uuid.UUID) -> int: + """Bytes of attachments on the notes this account owns.""" + total = await db.scalar( + select(func.coalesce(func.sum(NoteAttachment.size), 0)) + .join(Note, Note.id == NoteAttachment.note_id) + .where(Note.owner_id == user_id) + ) + return int(total) + + +async def storage_limit(db, user_id: uuid.UUID) -> int | None: + """The account's limit in bytes, or None when it has none.""" + gb = int(await get_setting(db, "storage_quota_gb")) + if gb <= 0: + return None + user = await db.get(User, user_id) + if user is not None and user.is_admin: + return None + return gb * GB + + +async def storage_room(db, user_id: uuid.UUID) -> int | None: + """Bytes the account may still attach, or None when it has no limit.""" + limit = await storage_limit(db, user_id) + if limit is None: + return None + return max(0, limit - await storage_used(db, user_id)) + + +def over_limit_message(limit: int) -> str: + return ( + f"this account has used its {limit // GB} GB of attachment storage; " + "delete some attachments or empty the trash to make room" + ) + + +async def upload_refusal(db, user_id: uuid.UUID, size: int) -> tuple[str, int] | None: + """Why a file of `size` bytes can't be attached, as (message, status), or None. + + The one check every upload makes: the per-file limit first, then the account's. + """ + max_mb = int(await get_setting(db, "max_attachment_mb")) + if size > max_mb * 1024 * 1024: + return f"file is too large (max {max_mb} MB)", 413 + limit = await storage_limit(db, user_id) + if limit is not None and size > limit - await storage_used(db, user_id): + return over_limit_message(limit), OVER_LIMIT_STATUS + return None diff --git a/src/inkwell/sync.py b/src/inkwell/sync.py index 7ac27dd..23e4b6b 100644 --- a/src/inkwell/sync.py +++ b/src/inkwell/sync.py @@ -40,9 +40,9 @@ from .notes import ( from .notes.body import write_body from .notes.helpers import _get_editable, _get_visible, store_attachment, unlink_media from .responses import json_error, not_found, parse_uuid -from .settings import get_setting from .retention import purge_note from .serialize import serialize_label_sync +from .storage import upload_refusal from .unfurl_queue import schedule as schedule_unfurls bp = Blueprint("sync", __name__, url_prefix="/api/sync") @@ -543,10 +543,14 @@ async def put_attachment(att_id: str): return jsonify({"id": str(aid), "status": "exists"}) if existing is not None: return json_error("attachment id already in use", 409) - max_mb = int(await get_setting(db, "max_attachment_mb")) + # Judged on the declared length first, so a file over either limit is + # refused before its bytes are read. + declared = request.content_length + if declared is not None and (refusal := await upload_refusal(db, g.user_id, declared)): + return json_error(*refusal) raw = await request.get_data() - if len(raw) > max_mb * 1024 * 1024: - return json_error(f"file is too large (max {max_mb} MB)", 413) + if refusal := await upload_refusal(db, g.user_id, len(raw)): + return json_error(*refusal) if hashlib.sha256(raw).hexdigest() != expected: return json_error("the file's bytes don't match its sha256", 400) store_attachment(db, note, raw, request.args.get("filename"), request.content_type, aid) diff --git a/tests/test_integration.py b/tests/test_integration.py index 63c1c15..746ee85 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1200,6 +1200,86 @@ async def test_a_short_password_leaves_the_reset_link_usable(app_client, db): assert ok.status_code == 200 +# --- Storage per account (#2939 §4) --------------------------------------------- + +_GB = 1024**3 + + +async def _attachment_on(note_id: str, size: int) -> uuid.UUID: + """An attachment row of `size` bytes, with no file behind it: what the limit + counts is the rows.""" + async with session_scope() as fresh: + row = NoteAttachment( + note_id=uuid.UUID(note_id), path="none", filename="big.bin", mime="application/octet-stream", size=size + ) + fresh.add(row) + await fresh.commit() + return row.id + + +def _upload(raw: bytes, name: str = "a.bin"): + import io + + from werkzeug.datastructures import FileStorage + + return {"file": FileStorage(io.BytesIO(raw), filename=name)} + + +async def test_an_account_at_its_storage_limit_is_refused_until_it_makes_room(app_client, db): + guest, _ = await _admin_and_guest(app_client) + nid = await _pushed_note(guest) + big = await _attachment_on(nid, 5 * _GB - 10) + + use = await (await guest.get("/api/auth/storage")).get_json() + assert use == {"used_bytes": 5 * _GB - 10, "limit_bytes": 5 * _GB} + + # 507, not 413: a native client tries a 5xx again next sync, and a 4xx never, + # so a file refused for want of room goes through once there is some. + synced = await _put(guest, str(uuid.uuid4()), nid, b"x" * 100) + assert synced.status_code == 507 + assert "5 GB" in (await synced.get_json())["error"] + uploaded = await guest.post(f"/api/notes/{nid}/attachments", files=_upload(b"x" * 100)) + assert uploaded.status_code == 507 + + # Still room for a file that fits. + assert (await _put(guest, str(uuid.uuid4()), nid, b"x" * 5)).status_code == 201 + + async with session_scope() as fresh: + await fresh.execute(delete(NoteAttachment).where(NoteAttachment.id == big)) + await fresh.commit() + assert (await guest.post(f"/api/notes/{nid}/attachments", files=_upload(b"x" * 100))).status_code == 201 + + +async def test_an_import_past_the_storage_limit_imports_nothing(app_client, db): + import io + import json + import zipfile + + guest, _ = await _admin_and_guest(app_client) + nid = await _pushed_note(guest) + await _attachment_on(nid, 5 * _GB - 4) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("Takeout/Keep/Photo.json", json.dumps({"textContent": "", "attachments": [{"filePath": "p.png"}]})) + zf.writestr("Takeout/Keep/p.png", b"png bytes") + resp = await guest.post("/api/notes/import", files=_upload(buf.getvalue(), "takeout.zip")) + assert resp.status_code == 507 + owner = (await (await guest.get("/api/auth/me")).get_json())["id"] + count = await db.scalar(select(func.count()).select_from(Note).where(Note.owner_id == uuid.UUID(owner))) + assert count == 1, "only the note that was already there" + + +async def test_an_admin_and_a_zero_setting_have_no_storage_limit(app_client, db): + guest, _ = await _admin_and_guest(app_client) + admin_use = await (await app_client.get("/api/auth/storage")).get_json() + assert admin_use["limit_bytes"] is None + + async with session_scope() as fresh: + await set_settings(fresh, {"storage_quota_gb": 0}) + await fresh.commit() + assert (await (await guest.get("/api/auth/storage")).get_json())["limit_bytes"] is None + + # --- Changing a password, and signing out everywhere else (#5105, practice 4) ---