3 Commits

Author SHA1 Message Date
c749d03376 Merge fix/topics-drawer-scroll-and-filter: topics drawer scroll + title filter
All checks were successful
continuous-integration/drone/push Build is passing
2026-06-22 09:05:46 +02:00
d7771887a1 fix(worker): silence false-positive integrity-test notifications
The pre-upload integrity test is advisory (never blocks upload), but it
raised a WARNING SystemNotification whenever `7z t` failed — most often
because large 7z archives OOM-kill the test process (SIGKILL / exit 137)
in the memory-limited worker container, which is a tool limitation, not
corruption. Classify failures as encrypted | corrupt | inconclusive;
suppress notifications for inconclusive (debug log only) while still
proceeding with the upload as before. Genuine corruption now uses the
INTEGRITY_AUDIT notification type instead of the misleading HASH_MISMATCH;
encrypted archives still notify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:57:25 +02:00
26bc43299a fix(telegram): make topics drawer scrollable + add topic title filter
The Radix ScrollArea viewport did not get a bounded height inside the
flex-column, max-h, vertically-centred dialog, so a long topic list
overflowed the dialog instead of scrolling (last topics unreachable).
Switch to a native flex-1 min-h-0 overflow-y-auto container, which is
the canonical touch-friendly flex-scroll pattern.

Also add a "Filter topics..." input (matches by topic title) so any
topic can be reached regardless of list length; reset the filter on
close; and add a toast fallback message on toggle failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:50:10 +02:00
3 changed files with 154 additions and 69 deletions

View File

@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useEffect, useCallback, useTransition } from "react"; import { useState, useEffect, useCallback, useMemo, useTransition } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Loader2 } from "lucide-react"; import { Loader2, Search } from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,7 +10,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { setTopicFetchEnabled } from "../actions"; import { setTopicFetchEnabled } from "../actions";
@@ -29,6 +29,10 @@ interface TopicsDrawerProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
function topicLabel(t: TopicRow): string {
return t.topicName ?? `Topic ${t.topicId}`;
}
export function TopicsDrawer({ export function TopicsDrawer({
channelId, channelId,
channelTitle, channelTitle,
@@ -37,6 +41,7 @@ export function TopicsDrawer({
}: TopicsDrawerProps) { }: TopicsDrawerProps) {
const [topics, setTopics] = useState<TopicRow[]>([]); const [topics, setTopics] = useState<TopicRow[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState("");
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [pendingId, setPendingId] = useState<string | null>(null); const [pendingId, setPendingId] = useState<string | null>(null);
@@ -57,6 +62,13 @@ export function TopicsDrawer({
if (open && channelId) fetchTopics(); if (open && channelId) fetchTopics();
}, [open, channelId, fetchTopics]); }, [open, channelId, fetchTopics]);
const handleOpenChange = (next: boolean) => {
// Reset the filter on close so the next open starts clean (the drawer is
// opened per-channel, so close-then-open is the normal channel switch).
if (!next) setFilter("");
onOpenChange(next);
};
const handleToggle = (topic: TopicRow, enabled: boolean) => { const handleToggle = (topic: TopicRow, enabled: boolean) => {
// Optimistic update // Optimistic update
setTopics((prev) => setTopics((prev) =>
@@ -66,7 +78,7 @@ export function TopicsDrawer({
startTransition(async () => { startTransition(async () => {
const result = await setTopicFetchEnabled(topic.id, enabled); const result = await setTopicFetchEnabled(topic.id, enabled);
if (!result.success) { if (!result.success) {
toast.error(result.error); toast.error(result.error ?? "Failed to update topic");
// Revert on failure // Revert on failure
setTopics((prev) => setTopics((prev) =>
prev.map((t) => prev.map((t) =>
@@ -78,20 +90,43 @@ export function TopicsDrawer({
}); });
}; };
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return topics;
return topics.filter((t) => topicLabel(t).toLowerCase().includes(q));
}, [topics, filter]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col gap-0 p-0"> <DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col gap-0 p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-1"> <DialogHeader className="px-6 pt-6 pb-4 border-b border-border space-y-3">
<DialogTitle className="truncate pr-8"> <div className="space-y-1">
Topics{channelTitle ? `: ${channelTitle}` : ""} <DialogTitle className="truncate pr-8">
</DialogTitle> Topics{channelTitle ? `: ${channelTitle}` : ""}
<DialogDescription> </DialogTitle>
Enabled topics are scanned and their files fetched. Disable a topic to <DialogDescription>
stop fetching new files from it already-fetched files are kept. Enabled topics are scanned and their files fetched. Disable a topic
</DialogDescription> to stop fetching new files from it already-fetched files are kept.
</DialogDescription>
</div>
{topics.length > 0 && (
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter topics..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="pl-9 h-9"
/>
</div>
)}
</DialogHeader> </DialogHeader>
<ScrollArea className="flex-1 min-h-0"> {/* Native overflow scroll: the Radix ScrollArea viewport does not get a
bounded height inside this flex-column, max-h, vertically-centred
dialog, so the list overflowed instead of scrolling. flex-1 +
min-h-0 + overflow-y-auto is the canonical, touch-friendly fix. */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="px-6 py-4 space-y-2"> <div className="px-6 py-4 space-y-2">
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-12"> <div className="flex items-center justify-center gap-2 py-12">
@@ -105,18 +140,24 @@ export function TopicsDrawer({
No topics discovered yet they&apos;ll appear here after the next No topics discovered yet they&apos;ll appear here after the next
scan. scan.
</p> </p>
) : filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No topics match &quot;{filter}&quot;.
</p>
) : ( ) : (
topics.map((topic) => ( filtered.map((topic) => (
<div <div
key={topic.id} key={topic.id}
className="flex items-center justify-between gap-3 rounded-md border p-3" className="flex items-center justify-between gap-3 rounded-md border p-3"
> >
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium"> <p className="truncate text-sm font-medium">
{topic.topicName ?? `Topic ${topic.topicId}`} {topicLabel(topic)}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{topic.fetchEnabled ? "Fetching enabled" : "Fetching disabled"} {topic.fetchEnabled
? "Fetching enabled"
: "Fetching disabled"}
{topic.lastScannedAt {topic.lastScannedAt
? ` · last scanned ${new Date( ? ` · last scanned ${new Date(
topic.lastScannedAt topic.lastScannedAt
@@ -128,15 +169,13 @@ export function TopicsDrawer({
checked={topic.fetchEnabled} checked={topic.fetchEnabled}
disabled={pendingId === topic.id} disabled={pendingId === topic.id}
onCheckedChange={(checked) => handleToggle(topic, checked)} onCheckedChange={(checked) => handleToggle(topic, checked)}
aria-label={`Toggle fetching for ${ aria-label={`Toggle fetching for ${topicLabel(topic)}`}
topic.topicName ?? topic.topicId
}`}
/> />
</div> </div>
)) ))
)} )}
</div> </div>
</ScrollArea> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -5,21 +5,28 @@ import { childLogger } from "../util/logger.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const log = childLogger("integrity"); const log = childLogger("integrity");
export type IntegrityFailureKind = "encrypted" | "corrupt" | "inconclusive";
export type IntegrityResult = export type IntegrityResult =
| { ok: true } | { ok: true }
| { ok: false; reason: string }; | { ok: false; reason: string; kind: IntegrityFailureKind };
/** /**
* Test that the archive can be read end-to-end without errors, BEFORE we * Test that the archive can be read end-to-end without errors, BEFORE we
* spend bandwidth uploading it to the destination channel. Catches: * spend bandwidth uploading it to the destination channel.
* - Truncated downloads (rare given our size check, but cheap to confirm)
* - CRC errors inside the archive
* - Bad central directories
* - Encrypted archives (we report them as failures rather than upload
* a file users can't extract)
* *
* Returns { ok: true } if the archive is intact. Returns * Failures are classified so the caller can react appropriately:
* { ok: false, reason } otherwise. Logs at warn level on failure. * - "encrypted" — password-protected; users can't extract it. Actionable.
* - "corrupt" — genuine CRC / structural error (truncated download, bad
* central directory, CRC mismatch). Actionable.
* - "inconclusive" — the test tool itself was killed (OOM) or timed out,
* typically on very large 7z archives in a memory-limited
* container (exit 137 / SIGKILL). This is a TOOL
* LIMITATION, not corruption — callers should NOT raise a
* user-facing alarm for it.
*
* Returns { ok: true } if the archive is intact, otherwise
* { ok: false, reason, kind }.
* *
* For multipart archives, pass the first part. unzip / unrar / 7z all * For multipart archives, pass the first part. unzip / unrar / 7z all
* auto-discover sibling parts. * auto-discover sibling parts.
@@ -42,7 +49,7 @@ export async function testArchiveIntegrity(
maxBuffer: 10 * 1024 * 1024, maxBuffer: 10 * 1024 * 1024,
}); });
if (stderr && stderr.trim()) { if (stderr && stderr.trim()) {
return { ok: false, reason: `unzip -t reported: ${stderr.slice(0, 500)}` }; return { ok: false, kind: "corrupt", reason: `unzip -t reported: ${stderr.slice(0, 500)}` };
} }
return { ok: true }; return { ok: true };
} }
@@ -58,7 +65,7 @@ export async function testArchiveIntegrity(
if (/All OK/i.test(combined)) { if (/All OK/i.test(combined)) {
return { ok: true }; return { ok: true };
} }
return { ok: false, reason: `unrar t did not report "All OK": ${combined.slice(-500)}` }; return { ok: false, kind: "corrupt", reason: `unrar t did not report "All OK": ${combined.slice(-500)}` };
} }
if (archiveType === "SEVEN_Z") { if (archiveType === "SEVEN_Z") {
@@ -70,24 +77,52 @@ export async function testArchiveIntegrity(
if (/Everything is Ok/i.test(combined)) { if (/Everything is Ok/i.test(combined)) {
return { ok: true }; return { ok: true };
} }
return { ok: false, reason: `7z t did not report "Everything is Ok": ${combined.slice(-500)}` }; return { ok: false, kind: "corrupt", reason: `7z t did not report "Everything is Ok": ${combined.slice(-500)}` };
} }
return { ok: false, reason: `Unknown archive type: ${archiveType}` }; return { ok: false, kind: "corrupt", reason: `Unknown archive type: ${archiveType}` };
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
// execFile throws on non-zero exit. Try to extract the most useful part. // execFile throws on non-zero exit, on timeout, and when killed by a signal.
// eslint-disable-next-line @typescript-eslint/no-explicit-any const e = err as {
const stderr = (err as any)?.stderr as string | undefined; stderr?: unknown;
stdout?: unknown;
signal?: string | null;
killed?: boolean;
code?: number | string | null;
};
const stderr = typeof e.stderr === "string" ? e.stderr : "";
const stdout = typeof e.stdout === "string" ? e.stdout : "";
const detail = stderr ? `: ${stderr.slice(0, 500)}` : ""; const detail = stderr ? `: ${stderr.slice(0, 500)}` : "";
const haystack = `${msg}\n${stdout}\n${stderr}`;
// Specifically flag encrypted archives so the caller can record a more // Encrypted archives — users can't extract them, so flag clearly.
// specific SkipReason / notification. if (/password|encrypted|wrong password|enter password/i.test(haystack)) {
if (/password|encrypted|need.*password/i.test(`${msg}${detail}`)) { return { ok: false, kind: "encrypted", reason: `Archive is encrypted (password protected): ${msg}${detail}` };
return { ok: false, reason: `Archive is encrypted (password protected): ${msg}${detail}` };
} }
// Inconclusive — the test tool was killed or timed out rather than
// reporting corruption. Common on large 7z in memory-limited containers,
// where `7z t` gets OOM-killed (SIGKILL / exit 137) mid-decompression.
// That's a tool limitation, not a corrupt archive.
const killedBySignal = e.signal === "SIGKILL" || e.signal === "SIGTERM";
const killedExitCode = e.code === 137 || e.code === 143; // 128 + SIGKILL/SIGTERM
const timedOut = e.killed === true;
const maxBufferExceeded = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
if (killedBySignal || killedExitCode || timedOut || maxBufferExceeded) {
log.debug(
{ err, archiveType, firstPartPath, signal: e.signal, code: e.code, killed: e.killed },
"Archive integrity test inconclusive (tool killed or timed out)"
);
return {
ok: false,
kind: "inconclusive",
reason: `Integrity test could not complete (tool killed or timed out — likely OOM on a large archive): ${msg}`,
};
}
// Genuine failure: a real non-zero exit with error output.
log.debug({ err, archiveType, firstPartPath }, "Archive integrity test failed"); log.debug({ err, archiveType, firstPartPath }, "Archive integrity test failed");
return { ok: false, reason: `Integrity test failed: ${msg}${detail}` }; return { ok: false, kind: "corrupt", reason: `Integrity test failed: ${msg}${detail}` };
} }
} }

View File

@@ -1941,34 +1941,45 @@ async function processOneArchiveSet(
if (!isMultipartZip) { if (!isMultipartZip) {
const integrity = await testArchiveIntegrity(archType, tempPaths[0]); const integrity = await testArchiveIntegrity(archType, tempPaths[0]);
if (!integrity.ok) { if (!integrity.ok) {
// Detect encryption specifically — those won't extract for users if (integrity.kind === "inconclusive") {
// even if we upload them. Surface clearly via notification but // The test tool was killed (OOM) or timed out — typically a large
// STILL proceed: the user can audit and decide what to do. // 7z in a memory-limited container. This is a tool limitation, NOT
const isEncrypted = /encrypted/i.test(integrity.reason); // corruption, so log quietly and DON'T raise a notification. The
accountLog.warn( // upload proceeds exactly as before.
{ fileName: archiveName, reason: integrity.reason.slice(0, 200), isEncrypted }, accountLog.debug(
"Archive integrity test failed — proceeding with upload anyway (advisory check)" { fileName: archiveName, reason: integrity.reason.slice(0, 200) },
); "Archive integrity test inconclusive — proceeding with upload (advisory check)"
try { );
await db.systemNotification.create({ } else {
data: { // Encrypted (won't extract for users) or genuinely corrupt — surface
type: isEncrypted ? "UPLOAD_FAILED" : "HASH_MISMATCH", // clearly via notification but STILL proceed: the user can audit and
severity: "WARNING", // decide what to do.
title: isEncrypted const isEncrypted = integrity.kind === "encrypted";
? `Archive may be encrypted: ${archiveName}` accountLog.warn(
: `Integrity test reported issues: ${archiveName}`, { fileName: archiveName, reason: integrity.reason.slice(0, 200), kind: integrity.kind },
message: integrity.reason.slice(0, 1000), "Archive integrity test failed — proceeding with upload anyway (advisory check)"
context: { );
fileName: archiveName, try {
sourceChannelId: channel.id, await db.systemNotification.create({
sourceMessageId: Number(archiveSet.parts[0].id), data: {
archiveType: archType, type: isEncrypted ? "UPLOAD_FAILED" : "INTEGRITY_AUDIT",
advisory: true, severity: "WARNING",
title: isEncrypted
? `Archive may be encrypted: ${archiveName}`
: `Integrity test reported issues: ${archiveName}`,
message: integrity.reason.slice(0, 1000),
context: {
fileName: archiveName,
sourceChannelId: channel.id,
sourceMessageId: Number(archiveSet.parts[0].id),
archiveType: archType,
advisory: true,
},
}, },
}, });
}); } catch {
} catch { // Best-effort notification
// Best-effort notification }
} }
} }
} }