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) ---