Each account may store 5 GB of attachments; admins have no limit
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m33s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m43s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s

#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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 11:12:26 -04:00
co-authored by Claude Opus 5.5
parent 4b4659157e
commit 043c87a8dc
12 changed files with 273 additions and 13 deletions
+16 -5
View File
@@ -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
+1
View File
@@ -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: {
+7
View File
@@ -110,6 +110,13 @@ export interface AuthRepo {
changePassword(current: string, next: string): Promise<SignedOutElsewhere>;
/** The same sign-out as `changePassword`, without changing the password. */
signOutElsewhere(): Promise<SignedOutElsewhere>;
/** Attachment storage this account uses, and its limit (null when it has none). */
storage(): Promise<StorageUse>;
}
export interface StorageUse {
used_bytes: number;
limit_bytes: number | null;
}
export interface SignedOutElsewhere {
+2
View File
@@ -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<SignedOutElsewhere>("/api/auth/password", { current_password: current, new_password: next }),
signOutElsewhere: () => api.post<SignedOutElsewhere>("/api/auth/sign-out-elsewhere"),
storage: () => api.get<StorageUse>("/api/auth/storage"),
},
devices: {
+34
View File
@@ -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<StorageUse | null>(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(() => {
</li>
</ul>
<template v-if="storage">
<h2 class="mb-2 mt-10 text-xs font-semibold uppercase tracking-wide text-neutral-400">Storage</h2>
<div
class="flex items-center justify-between gap-4 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="min-w-0">
<p class="text-sm font-medium text-neutral-800 dark:text-neutral-100">Attachments</p>
<p class="text-xs text-neutral-400">Trash counts until it's emptied</p>
</div>
<p class="shrink-0 text-sm tabular-nums text-neutral-600 dark:text-neutral-300">{{ storageLine(storage) }}</p>
</div>
</template>
<h2 class="mb-2 mt-10 text-xs font-semibold uppercase tracking-wide text-neutral-400">Password</h2>
<p class="mb-4 text-sm text-neutral-500 dark:text-neutral-400">Changing it signs you out everywhere else.</p>
<form class="flex max-w-sm flex-col gap-3" @submit.prevent="changePassword">
+15
View File
@@ -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."
+10 -4
View File
@@ -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()
+11
View File
@@ -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
+11
View File
@@ -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",
+78
View File
@@ -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
+8 -4
View File
@@ -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)
+80
View File
@@ -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) ---