Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ All notable changes to Sheaf are documented here. The format is based on [Keep a

### Fixed

- **Member banners on public profiles now show in full.** The public member card gave the banner a fixed height and cropped the image to fit it, so the top and bottom of the 3:1 banner the owner had framed in the cropper never showed on the one page it was framed for. The card now renders the banner at its own 3:1 shape, the same as the member list and member dialog in the app.
- **The quick-switch list no longer loads every member to show eight.** Working out who fronts most often ranked the whole roster in the database already, but then loaded every member's full record, bio included, to sort by pin and score before returning at most eight. On a large system with long bios that was the entire cost of the request, and it ran on every quick-switch poll. It now fetches only ids and pins for the ranking, then loads just the members it returns. A `sheaf_system_member_count_max` gauge (and its `sheaf_systems_by_member_count` distribution) joins the other per-system maxima so the next question of whether a huge system exists has an answer.
- **Imports start immediately again, instead of sitting for a few seconds first.** The runner keeps a database connection open to be told the instant an import is queued, so a file you upload starts processing in milliseconds rather than waiting for the next poll. That connection was opening a database transaction it never closed, and Postgres only hands a notification to a connection that is between transactions, so a minute after the server started the notifications quietly stopped arriving. Nothing was lost or stuck, because the timed poll behind it always picked the job up anyway, but every import paid a short wait it was not supposed to. Two things made it invisible: the poll is a deliberate safety net, so the symptom was "slightly slower" rather than "broken", and the test covering it finished before the transaction ever opened. It now also no longer holds the oldest transaction on the database, which was hiding genuinely stuck transactions from monitoring. This is the same fault that was fixed in the leader election earlier; these were the only two places with the pattern.

### Added

- **A banner image for the system, the same as a member's.** Settings, System profile gains a Banner control beside the avatar: the same upload and cropper flow as a member banner (3:1, zoom and rotate, letterboxing allowed), the same server-side re-encoding, or an external image URL where the instance allows those. It shows at the top of the public profile and share links, above the avatar and name, withheld from visitors when it points at an external host exactly as avatars are. Round-trips through the native export and archive, PluralPort (`System.banner_asset_id`), and the orphaned-file cleanup, and is dropped on import when it points at another account's storage, all mirroring the member banner. API clients: `banner_url` on `GET/PATCH /v1/systems/me` and on the public system view.
- **An opt-in extended metrics tier, starting with active accounts by client version.** Self-hosters with metrics enabled only. `METRICS_EXTENDED=true` turns on a second tier of metrics for the questions whose answers need more series or short-lived per-account state, off by default so no instance inherits the scrape cost or the data-handling posture without asking. Every metric in it is named `sheaf_ext_*` and every Redis key `sheaf:ext:*`, so one regex routes or drops the lot in a pipeline; per-account state is folded under a day-salted token that cannot be joined across days and nothing outlives 48 hours. The first metric is `sheaf_ext_active_accounts_by_version{client_family, version}`: distinct accounts active today per client family and `major.minor` client version, the number that decides how long an API compatibility shim has to stay. The tier's own settings share the `METRICS_EXTENDED_` prefix; the first, `METRICS_EXTENDED_VERSION_PAIRS_PER_DAY` (default 64), bounds how many distinct versions a day may hold before new ones fold into `other`. Documented in `docs/METRICS.md`.
- **The extended metrics tier gains its planned set.** Self-hosters with `METRICS_EXTENDED=true` only. Requests by route and client family (`sheaf_ext_http_requests_by_client_total`, the RED counter multiplied by family, for the cross-client drift question); distinct accounts by the exact combination of clients they used today (`sheaf_ext_accounts_by_client_pattern`, computed by set algebra inside Redis so no account is ever read out); active accounts by family and account age (`sheaf_ext_active_accounts_daily`); and two once-a-day histograms folded from yesterday's per-token counters, requests per account per day by family (`sheaf_ext_account_requests_daily`) and served public-profile requests per profile per day by grant type (`sheaf_ext_public_requests_per_profile_daily`). Every per-account key is day-salted, lives at most 48 hours, and the fold job deletes the counters as it observes them. Documented in `docs/METRICS.md`.
- **The member editor now says who can see each custom field.** Web. Each custom field in the member editor, and each field on the member detail view, carries its privacy level beside it as a small tinted word (Private, Friends only, Public), with the same "activates on" note the settings card shows when a raise is waiting out a grace period. Before this the editor gave no hint at all, so "wait, was that one public?" meant leaving the member, checking Settings, and coming back. The level is still changed in Settings, since it belongs to the field and applies to every member.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ A plural system's records are among the most sensitive data a person can keep, a
- **Account deletion** — Self-service with configurable grace period
- **Field-level encryption** — Member names/bios, journal titles/bodies, and revision history encrypted at rest with XChaCha20-Poly1305
- **Appearance** - 15 colour palettes (Classic, OLED, Sepia, Ocean, several pride flags, and more) crossed with light / dark / follow-my-system, defaulting to dark, with Dark Reader compatibility. Your pick can sync across your devices through your account or stay local to one browser, your choice.
- **Image uploads** - Avatars, member banners, and images embedded in bios and journals. An in-browser cropper (with zoom and rotate) frames the image before it is sent, and every accepted upload is re-encoded server-side: EXIF stripped, dimensions capped, decompression bombs refused, animation flattened unless the operator allows it.
- **Image uploads** - Avatars, system and member banners, and images embedded in bios and journals. An in-browser cropper (with zoom and rotate) frames the image before it is sent, and every accepted upload is re-encoded server-side: EXIF stripped, dimensions capped, decompression bombs refused, animation flattened unless the operator allows it.

## FAQ

Expand Down
31 changes: 31 additions & 0 deletions alembic/versions/b2a3n4n5e6r7_add_system_banner_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Add banner_url to systems

Revision ID: b2a3n4n5e6r7
Revises: a2b3c4d5e6f7
Create Date: 2026-09-25

Wide header image for the system profile, the system-level twin of
Member.banner_url. Same storage/trust model as avatar_url (bare storage
key or external URL); nullable, no default.
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "b2a3n4n5e6r7"
down_revision: Union[str, None] = "a2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"systems",
sa.Column("banner_url", sa.String(length=500), nullable=True),
)


def downgrade() -> None:
op.drop_column("systems", "banner_url")
1 change: 1 addition & 0 deletions docs/PLURALPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Sheaf fields that land in PluralPort core records.
|---|---|
| `system.id` / `name` / `description` / `tag` / `color` | `System.id` / `name` / `description` / `tag` / `color` |
| `system.avatar_url` | `Asset` (kind `avatar`) + `System.avatar_asset_id` |
| `system.banner_url` | `Asset` (kind `banner`) + `System.banner_asset_id` |
| `system.privacy` | `System.privacy` (visibility bucket; see edge cases) |
| `members[].id` / `name` / `display_name` / `description` / `pronouns` / `color` | `Member.id` / `name` / `display_name` / `description` / `pronouns` / `color` |
| `members[].avatar_url` | `Asset` (kind `avatar`) + `Member.avatar_asset_id` |
Expand Down
1 change: 1 addition & 0 deletions sheaf/api/v1/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,7 @@ def _system_dict(system: System) -> dict:
),
"tag": system.tag,
"avatar_url": system.avatar_url,
"banner_url": system.banner_url,
"color": system.color,
"privacy": system.privacy.value,
# User-set system preferences. Re-import should restore these.
Expand Down
2 changes: 2 additions & 0 deletions sheaf/api/v1/systems.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ async def update_own_system(
# it is stored (and later re-signed on read) - cross-tenant read oracle.
if "avatar_url" in update_data:
update_data["avatar_url"] = owned_avatar_url(update_data["avatar_url"], user.id)
if "banner_url" in update_data:
update_data["banner_url"] = owned_avatar_url(update_data["banner_url"], user.id)
if "description" in update_data:
update_data["description"] = owned_description_urls(
update_data["description"], user.id
Expand Down
3 changes: 3 additions & 0 deletions sheaf/models/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class System(UUIDMixin, TimestampMixin, Base):
note: Mapped[str | None] = mapped_column(Text, nullable=True, info={"encrypted": True})
tag: Mapped[str | None] = mapped_column(String(8), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Wide header image for the system profile. Same storage/trust model as
# avatar_url and the member twin, Member.banner_url.
banner_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
privacy: Mapped[PrivacyLevel] = mapped_column(
Enum(PrivacyLevel, values_callable=lambda e: [m.value for m in e]),
Expand Down
1 change: 1 addition & 0 deletions sheaf/schemas/public_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class PublicSystemView(BaseModel):
name: str
description: str | None = None
avatar_url: str | None = None
banner_url: str | None = None
color: str | None = None
tag: str | None = None
# Count of members actually visible in this view (after the hard guards),
Expand Down
11 changes: 8 additions & 3 deletions sheaf/schemas/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ class SystemCreate(BaseModel):
note: str | None = Field(default=None, max_length=5000)
tag: str | None = Field(default=None, max_length=8)
avatar_url: str | None = Field(default=None, max_length=500)
banner_url: str | None = Field(default=None, max_length=500)
color: str | None = Field(default=None, max_length=7)
privacy: PrivacyLevel = PrivacyLevel.PRIVATE

@field_validator("avatar_url", mode="before")
# banner_url shares the avatar normaliser, exactly as the member schemas
# do: both are image storage keys / external URLs with the same rules.
@field_validator("avatar_url", "banner_url", mode="before")
@classmethod
def _normalize_avatar(cls, v: str | None) -> str | None:
return normalize_avatar_url(v)
Expand All @@ -44,6 +47,7 @@ class SystemUpdate(BaseModel):
note: str | None = Field(default=None, max_length=5000)
tag: str | None = Field(default=None, max_length=8)
avatar_url: str | None = Field(default=None, max_length=500)
banner_url: str | None = Field(default=None, max_length=500)
color: str | None = Field(default=None, max_length=7)
privacy: PrivacyLevel | None = None
date_format: DateFormat | None = None
Expand All @@ -64,7 +68,7 @@ class SystemUpdate(BaseModel):
)
totp_code: str | None = None

@field_validator("avatar_url", mode="before")
@field_validator("avatar_url", "banner_url", mode="before")
@classmethod
def _normalize_avatar(cls, v: str | None) -> str | None:
return normalize_avatar_url(v)
Expand Down Expand Up @@ -113,6 +117,7 @@ class SystemRead(BaseModel):
note: str | None
tag: str | None
avatar_url: str | None
banner_url: str | None = None
color: str | None
privacy: PrivacyLevel
# A raise of the master switch waiting out the grace window: `privacy` above
Expand Down Expand Up @@ -140,7 +145,7 @@ class SystemRead(BaseModel):

model_config = {"from_attributes": True}

@field_serializer("avatar_url")
@field_serializer("avatar_url", "banner_url")
def _sign_avatar_url(self, v: str | None) -> str | None:
return resolve_avatar_url(v, self.user_id)

Expand Down
14 changes: 11 additions & 3 deletions sheaf/services/file_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ async def find_orphaned_files(
# Collect all referenced keys for this user
referenced: set[str] = set()

# System avatar
# System avatar and banner
result = await db.execute(
select(System.avatar_url).join(User).where(User.id == user_id)
select(System.avatar_url, System.banner_url).join(User).where(User.id == user_id)
)
for (avatar_url,) in result:
for avatar_url, banner_url in result:
referenced.update(_key_from_avatar(avatar_url))
referenced.update(_key_from_avatar(banner_url))

# Member avatars, banners, and bios. Member.id is projected so the
# description ciphertext can be decrypted under its per-cell aad.
Expand Down Expand Up @@ -185,6 +186,13 @@ async def find_file_references(
"target_type": "system",
"target_id": str(system.id),
})
if key in _key_from_avatar(system.banner_url):
refs.append({
"kind": "system_banner",
"label": "System banner",
"target_type": "system",
"target_id": str(system.id),
})

# Members: avatars + bio image embeds. Names/bios are encrypted, so decrypt
# via member_plaintext to both scan and label.
Expand Down
1 change: 1 addition & 0 deletions sheaf/services/import_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class Cap(NamedTuple):
SYS_DESCRIPTION = Cap("system description", 20000)
SYS_TAG = Cap("system tag", 8)
SYS_AVATAR_URL = Cap("system avatar URL", 500)
SYS_BANNER_URL = Cap("system banner URL", 500)
SYS_COLOR = Cap("system color", 7)

# --- Group / tag ------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions sheaf/services/pluralport_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ def build_envelope(
"tag": sys_data.get("tag"),
"color": sys_data.get("color"),
"avatar_asset_id": assets.ref(sys_data.get("avatar_url"), kind="avatar"),
"banner_asset_id": assets.ref(sys_data.get("banner_url"), kind="banner"),
"privacy": _privacy_obj(sys_data.get("privacy")),
"extensions": {EXT_NS: _prune(sys_ext)},
}
Expand Down
1 change: 1 addition & 0 deletions sheaf/services/pluralport_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ def to_native(envelope: dict, assets: _AssetMap | None = None) -> dict:
"note": ext.get("note"),
"tag": sys_in.get("tag"),
"avatar_url": assets.url(sys_in.get("avatar_asset_id")),
"banner_url": assets.url(sys_in.get("banner_asset_id")),
"color": sys_in.get("color"),
"privacy": _op_privacy(sys_in.get("privacy")),
"date_format": ext.get("date_format"),
Expand Down
1 change: 1 addition & 0 deletions sheaf/services/share_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,7 @@ def _build_system_view(
system.description, system.user_id
),
avatar_url=resolve_avatar_url_public(system.avatar_url, system.user_id),
banner_url=resolve_avatar_url_public(system.banner_url, system.user_id),
color=system.color,
tag=system.tag,
member_count=member_count,
Expand Down
1 change: 1 addition & 0 deletions sheaf/services/sheaf_archive_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ def _from_key_list(values: object, site: str) -> None:
sys_data = data.get("system")
if isinstance(sys_data, dict):
_from_url(sys_data.get("avatar_url"), "system avatar")
_from_url(sys_data.get("banner_url"), "system banner")
for fld in _MD_FIELDS_SYSTEM:
_from_md(sys_data.get(fld), f"system {fld}")

Expand Down
8 changes: 8 additions & 0 deletions sheaf/services/sheaf_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@ def s(value: object, cap: il.Cap) -> None:
s(sys_data.get("tag"), il.SYS_TAG)
s(sys_data.get("color"), il.SYS_COLOR)
s(sys_data.get("avatar_url"), il.SYS_AVATAR_URL)
s(sys_data.get("banner_url"), il.SYS_BANNER_URL)
s(sys_data.get("note"), il.SYS_NOTE)

for m in _as_list(data.get("members")):
Expand Down Expand Up @@ -1085,6 +1086,13 @@ def _resolve_image_keys(keys: list[str] | None) -> list[str]:
il.SYS_AVATAR_URL,
report=report,
)
if sys_data.get("banner_url") is not None:
# Same treatment as the avatar above, and as the member banner.
system.banner_url = clamp_str(
_resolve_avatar_url(sys_data["banner_url"]),
il.SYS_BANNER_URL,
report=report,
)
if "replace_fronts_default" in sys_data:
system.replace_fronts_default = bool(
sys_data["replace_fronts_default"]
Expand Down
3 changes: 2 additions & 1 deletion tests/test_export_import_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@
},
System: {
"exported": {
"name", "description", "note", "tag", "avatar_url", "color",
"name", "description", "note", "tag", "avatar_url", "banner_url",
"color",
"privacy", "delete_confirmation", "date_format", "timezone",
"replace_fronts_default", "coalesce_contiguous_fronts",
"show_member_created_date",
Expand Down
7 changes: 7 additions & 0 deletions tests/test_pluralport_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def _native() -> dict:
"system": {
"id": "s1", "name": "Sys", "description": "d", "note": "sysnote",
"tag": "|S|", "avatar_url": "/v1/files/avatars/u/a.png",
"banner_url": "/v1/files/banners/u/b.png",
"color": "#fff", "privacy": "public", "date_format": "ymd",
"timezone": "America/New_York",
"replace_fronts_default": True, "coalesce_contiguous_fronts": False,
Expand Down Expand Up @@ -104,6 +105,12 @@ def test_core_records_mapped():
assert env["systems"][0]["name"] == "Sys"
# privacy is the PluralPort Privacy object, not a bare string.
assert env["systems"][0]["privacy"] == {"visibility": "public"}
# The system's avatar and banner each become an Asset of their kind,
# referenced by id, exactly as a member's do.
by_asset = {a["id"]: a for a in env["assets"]}
sys_out = env["systems"][0]
assert by_asset[sys_out["avatar_asset_id"]]["kind"] == "avatar"
assert by_asset[sys_out["banner_asset_id"]]["kind"] == "banner"
# pluralkit_id becomes a source_ref, not a core member field.
m1 = next(m for m in env["members"] if m["id"] == "m1")
assert {"app": "pluralkit", "collection": "members", "id": "abcde"} in m1["source_refs"]
Expand Down
2 changes: 1 addition & 1 deletion tests/test_pluralport_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"System": {
# Core PluralPort System fields.
"name": CORE, "description": CORE, "tag": CORE, "color": CORE,
"privacy": CORE, "avatar_url": CORE,
"privacy": CORE, "avatar_url": CORE, "banner_url": CORE,
# extensions.sheaf.* (note + prefs + the safety/retention blocks).
"note": EXT, "date_format": EXT, "timezone": EXT,
"replace_fronts_default": EXT,
Expand Down
Loading
Loading