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
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:
+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