AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
+16
-5
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user