Compare commits

...

4 Commits

Author SHA1 Message Date
byGalax d5c54166a4 fix(migrate): restore storage object xattrs lost in the server move
CI / verify (push) Has been cancelled
After the netralax.cloud -> netralax.de migration every storage object GET
returned HTTP 500 (ENODATA "The extended attribute does not exist"). Root
cause: the object bytes were copied but Supabase Storage (file backend,
v1.48.26) keeps each object's response metadata in Linux xattrs
(user.supabase.{content-type,cache-control,etag}); the copy did not preserve
them. The old server is gone, but the values survive in storage.objects.metadata,
so this script reconstructs the xattrs from the DB. Verified: public avatar GETs
went 500 -> 200 after running it; all 18 objects (avatars, banner, attachments)
restored, 0 files genuinely missing. Idempotent, touches no object bytes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:59:46 +02:00
byGalax f438018400 chore(desktop): release v0.21.11 2026-06-02 23:07:34 +02:00
byGalax 89003f71a4 fix(desktop): remove chat-switch reveal flicker (decouple from reactions)
The residual flicker on chat switch was a loading/reveal artifact, not scroll.
listReady gated the MessageList reveal on reactionsReady OR a 300ms timeout, so
on a cache-hit switch (messages already present from the first render) the list
sat at opacity:0 for up to 300ms and then popped in. Drop the reactions/timeout
gate: reveal as soon as messages exist. Reaction chips stream in a beat later;
because the list is pinned to the bottom their height growth re-pins with no
visible jump, and MessageList still defers its own reveal a few frames until the
row-height measurement settles so it appears already at the final bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:02:07 +02:00
byGalax b364c53c61 fix(desktop): degrade missing avatar image to letter circle
Avatar rendered a bare <img> with no error handling, so an avatar_url whose
storage object is unreachable (e.g. a 404 after the server move) showed a
broken image instead of the coloured letter-circle fallback. Track an onError
flag and fall back to the circle; reset it when the URL changes so a fresh
valid avatar is retried. This is client-side resilience only — it does not
restore a genuinely missing storage object.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:02:07 +02:00
4 changed files with 148 additions and 15 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@chat-app/desktop", "name": "@chat-app/desktop",
"version": "0.21.10", "version": "0.21.11",
"private": true, "private": true,
"description": "Electron desktop client (Windows / macOS / Linux)", "description": "Electron desktop client (Windows / macOS / Linux)",
"type": "module", "type": "module",
+10 -1
View File
@@ -1,6 +1,8 @@
// Reusable avatar that prefers an uploaded image and falls back to a coloured // Reusable avatar that prefers an uploaded image and falls back to a coloured
// letter circle. Use this everywhere the app needs to render a profile. // letter circle. Use this everywhere the app needs to render a profile.
import { useEffect, useState } from 'react';
import { useCachedAvatarUrl } from '../lib/avatarCache'; import { useCachedAvatarUrl } from '../lib/avatarCache';
interface Props { interface Props {
@@ -27,7 +29,13 @@ export function Avatar({
loading = 'lazy', loading = 'lazy',
}: Props) { }: Props) {
const effectiveUrl = useCachedAvatarUrl(url); const effectiveUrl = useCachedAvatarUrl(url);
if (effectiveUrl) { // If the image URL is non-empty but unreachable (e.g. the storage object is
// missing / 404s), the bare <img> would render broken with no fallback.
// Track a load error and degrade to the letter circle instead. Reset on URL
// change so a fresh, valid avatar is retried.
const [failed, setFailed] = useState(false);
useEffect(() => setFailed(false), [effectiveUrl]);
if (effectiveUrl && !failed) {
return ( return (
<img <img
src={effectiveUrl} src={effectiveUrl}
@@ -35,6 +43,7 @@ export function Avatar({
className={'shrink-0 rounded-full object-cover ' + className} className={'shrink-0 rounded-full object-cover ' + className}
draggable={false} draggable={false}
loading={loading} loading={loading}
onError={() => setFailed(true)}
/> />
); );
} }
+10 -13
View File
@@ -144,21 +144,18 @@ export function ConversationPage() {
byMessage: reactionsByMessage, byMessage: reactionsByMessage,
toggle: toggleReaction, toggle: toggleReaction,
voteExclusive: votePoll, voteExclusive: votePoll,
ready: reactionsReady,
} = useMessageReactions(messageIds, session?.user.id); } = useMessageReactions(messageIds, session?.user.id);
// Deferred-reveal gate for MessageList: keep the list hidden until messages // Reveal gate for MessageList: as soon as messages exist (cache hit = first
// AND their reactions (the main post-paint height changer) are loaded, so the // render, so no spinner and no wait), let the list reveal. We deliberately do
// chat opens already-stable instead of flickering through the load cascade. // NOT gate on reactions readiness: on a cache-hit chat switch the messages are
// A 300 ms max-timeout ensures a slow/empty reactions fetch never hangs it. // already present, and gating on the async reactions fetch held the list at
const [revealTimedOut, setRevealTimedOut] = useState(false); // opacity:0 for up to 300ms and then "popped" it in — that was the residual
useEffect(() => { // chat-switch flicker. Reaction chips stream in a beat later; because the list
setRevealTimedOut(false); // is pinned to the bottom, their height growth re-pins with no visible jump.
if (!id || loading || messages.length === 0) return; // MessageList still defers its own reveal a few frames until the row-height
const tmo = window.setTimeout(() => setRevealTimedOut(true), 300); // measurement settles, so the list still appears already at the final bottom.
return () => window.clearTimeout(tmo); const listReady = !loading && messages.length > 0;
}, [id, loading, messages.length]);
const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
const myId = session?.user.id; const myId = session?.user.id;
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# Incident fix (2026-06-02): after the netralax.cloud -> netralax.de move, every
# storage object GET returned HTTP 500 with:
# { "code": "ENODATA", "errno": 61, "message": "The extended attribute does not exist." }
#
# Root cause: the OBJECT BYTES were copied to the new server, but Supabase
# Storage (supabase/storage-api:v1.48.26, file backend) keeps each object's
# response metadata in Linux extended attributes (xattrs) on the version file:
# user.supabase.content-type
# user.supabase.cache-control
# user.supabase.etag
# The migration copy did not preserve xattrs, so storage's getObject() throws
# ENODATA when it reads them. The OLD server is gone, so we cannot re-copy —
# but every value we need is still in the database column storage.objects.metadata
# (mimetype / cacheControl / eTag). This script reconstructs the missing xattrs
# from that column. It is idempotent and only ADDS metadata xattrs; it never
# touches object bytes.
#
# RUN ON THE NEW SERVER (needs /opt/supabase, docker, and root for setxattr):
# sudo python3 04-restore-storage-xattrs.py # all buckets
# sudo python3 04-restore-storage-xattrs.py --dry-run # show, change nothing
# sudo python3 04-restore-storage-xattrs.py --bucket profile-avatars
# After it finishes, no storage restart is needed (xattrs are read per request).
import argparse
import json
import os
import subprocess
import sys
SUPABASE_DIR = "/opt/supabase"
# Single-tenant self-hosted layout: <volume>/stub/stub/<bucket>/<name>/<version-file>
STORAGE_ROOT = os.path.join(SUPABASE_DIR, "volumes/storage/stub/stub")
# DB metadata field -> (xattr name, default when the field is absent)
XATTRS = [
("mimetype", "user.supabase.content-type", "application/octet-stream"),
("cacheControl", "user.supabase.cache-control", "no-cache"),
("eTag", "user.supabase.etag", None), # None default => skip if missing
]
def fetch_objects():
"""Return [(bucket_id, name, metadata_dict), ...] from storage.objects."""
# Tab-separate so object names containing '|' can't break parsing.
query = (
"select bucket_id||chr(9)||name||chr(9)||coalesce(metadata::text,'{}') "
"from storage.objects"
)
raw = subprocess.check_output(
[
"docker", "compose", "exec", "-T", "db",
"psql", "-U", "postgres", "-d", "postgres", "-tAc", query,
],
cwd=SUPABASE_DIR,
).decode()
rows = []
for line in raw.splitlines():
line = line.rstrip("\r")
if not line.strip():
continue
parts = line.split("\t", 2)
if len(parts) < 3:
continue
bucket, name, meta = parts
try:
md = json.loads(meta) if meta else {}
except json.JSONDecodeError:
md = {}
rows.append((bucket, name, md))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--bucket", help="only this bucket (e.g. profile-avatars)")
ap.add_argument("--dry-run", action="store_true", help="print, change nothing")
args = ap.parse_args()
objects = fetch_objects()
fixed = missing_dir = missing_file = 0
for bucket, name, md in objects:
if args.bucket and bucket != args.bucket:
continue
objdir = os.path.join(STORAGE_ROOT, bucket, name)
if not os.path.isdir(objdir):
print("NO_DIR ", bucket, name)
missing_dir += 1
continue
version_files = [
os.path.join(objdir, f)
for f in os.listdir(objdir)
if os.path.isfile(os.path.join(objdir, f))
]
if not version_files:
print("NO_FILE ", bucket, name)
missing_file += 1
continue
for path in version_files:
for field, xattr, default in XATTRS:
value = md.get(field, default)
if value is None:
continue
if args.dry_run:
print(f" would set {xattr}={value!r} on {path}")
else:
os.setxattr(path, xattr, str(value).encode())
fixed += 1
print("OK ", bucket, name)
print(
f"\n{'DRY-RUN: would fix' if args.dry_run else 'fixed'} {fixed} file(s); "
f"{missing_dir} missing dir(s), {missing_file} empty object dir(s)."
)
if missing_dir or missing_file:
print(
"NOTE: objects with a missing dir/file have lost their bytes and "
"cannot be recovered from xattrs — those are genuinely gone."
)
if __name__ == "__main__":
if os.geteuid() != 0 and "--dry-run" not in sys.argv:
print("Re-run with sudo (setxattr needs root).", file=sys.stderr)
sys.exit(1)
main()