mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-22 23:12:02 +00:00
Compare commits
3 Commits
8b500a1610
...
c749d03376
| Author | SHA1 | Date | |
|---|---|---|---|
| c749d03376 | |||
| d7771887a1 | |||
| 26bc43299a |
@@ -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">
|
||||||
|
<div className="space-y-1">
|
||||||
<DialogTitle className="truncate pr-8">
|
<DialogTitle className="truncate pr-8">
|
||||||
Topics{channelTitle ? `: ${channelTitle}` : ""}
|
Topics{channelTitle ? `: ${channelTitle}` : ""}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enabled topics are scanned and their files fetched. Disable a topic to
|
Enabled topics are scanned and their files fetched. Disable a topic
|
||||||
stop fetching new files from it — already-fetched files are kept.
|
to stop fetching new files from it — already-fetched files are kept.
|
||||||
</DialogDescription>
|
</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'll appear here after the next
|
No topics discovered yet — they'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 "{filter}".
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1941,18 +1941,28 @@ 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
|
||||||
|
// upload proceeds exactly as before.
|
||||||
|
accountLog.debug(
|
||||||
|
{ fileName: archiveName, reason: integrity.reason.slice(0, 200) },
|
||||||
|
"Archive integrity test inconclusive — proceeding with upload (advisory check)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Encrypted (won't extract for users) or genuinely corrupt — surface
|
||||||
|
// clearly via notification but STILL proceed: the user can audit and
|
||||||
|
// decide what to do.
|
||||||
|
const isEncrypted = integrity.kind === "encrypted";
|
||||||
accountLog.warn(
|
accountLog.warn(
|
||||||
{ fileName: archiveName, reason: integrity.reason.slice(0, 200), isEncrypted },
|
{ fileName: archiveName, reason: integrity.reason.slice(0, 200), kind: integrity.kind },
|
||||||
"Archive integrity test failed — proceeding with upload anyway (advisory check)"
|
"Archive integrity test failed — proceeding with upload anyway (advisory check)"
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await db.systemNotification.create({
|
await db.systemNotification.create({
|
||||||
data: {
|
data: {
|
||||||
type: isEncrypted ? "UPLOAD_FAILED" : "HASH_MISMATCH",
|
type: isEncrypted ? "UPLOAD_FAILED" : "INTEGRITY_AUDIT",
|
||||||
severity: "WARNING",
|
severity: "WARNING",
|
||||||
title: isEncrypted
|
title: isEncrypted
|
||||||
? `Archive may be encrypted: ${archiveName}`
|
? `Archive may be encrypted: ${archiveName}`
|
||||||
@@ -1972,6 +1982,7 @@ async function processOneArchiveSet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Uploading ──
|
// ── Uploading ──
|
||||||
// Check if a prior run already uploaded this file (orphaned upload scenario:
|
// Check if a prior run already uploaded this file (orphaned upload scenario:
|
||||||
|
|||||||
Reference in New Issue
Block a user