feat: standardize input styling with tier system, add depth and admin features
- Define 4 input tier CSS classes (input-standard, input-search, input-embedded, input-danger) in globals.css, migrating ~50 inputs across ~28 component files to use them - Add subtle border and inset shadow to solid input tiers for resting-state visibility - Fix focus ring clipping in settings panel scroll container - Fix phantom Tailwind tokens (border-border-primary, placeholder-txt-muted) - Add admin user management panel, storage management, and account deletion utilities
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { eq, isNotNull } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { deleteUploadFile } from './fileCleanup.js';
|
||||
import type { StorageStats, StorageBreakdown, OrphanedFile, CleanupResult } from '@backspace/shared';
|
||||
|
||||
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.avif']);
|
||||
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']);
|
||||
const AUDIO_EXTS = new Set(['.mp3', '.ogg', '.wav', '.flac', '.aac', '.m4a', '.opus']);
|
||||
const DOC_EXTS = new Set(['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv', '.json', '.xml']);
|
||||
|
||||
/** 1-hour threshold: attachments uploaded but never linked to a message */
|
||||
const UNLINKED_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
function classifyFile(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||
if (VIDEO_EXTS.has(ext)) return 'video';
|
||||
if (AUDIO_EXTS.has(ext)) return 'audio';
|
||||
if (DOC_EXTS.has(ext)) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
interface DiskFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
|
||||
function getDiskFiles(): DiskFile[] {
|
||||
try {
|
||||
const entries = fs.readdirSync(config.uploadDir, { withFileTypes: true });
|
||||
const files: DiskFile[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
try {
|
||||
const stat = fs.statSync(path.join(config.uploadDir, entry.name));
|
||||
files.push({
|
||||
filename: entry.name,
|
||||
size: stat.size,
|
||||
modifiedAt: stat.mtimeMs,
|
||||
});
|
||||
} catch {
|
||||
// Skip files that vanished between readdir and stat
|
||||
}
|
||||
}
|
||||
return files;
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getReferencedFilenames(): Set<string> {
|
||||
const db = getDb();
|
||||
const referenced = new Set<string>();
|
||||
|
||||
// Attachment filenames
|
||||
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments).all();
|
||||
for (const row of attachmentRows) {
|
||||
referenced.add(path.basename(row.filename));
|
||||
}
|
||||
|
||||
// Attachment thumbnails
|
||||
const thumbRows = db.select({ thumbnailFilename: schema.attachments.thumbnailFilename })
|
||||
.from(schema.attachments)
|
||||
.where(isNotNull(schema.attachments.thumbnailFilename))
|
||||
.all();
|
||||
for (const row of thumbRows) {
|
||||
if (row.thumbnailFilename) referenced.add(path.basename(row.thumbnailFilename));
|
||||
}
|
||||
|
||||
// User avatars
|
||||
const avatarRows = db.select({ avatar: schema.users.avatar })
|
||||
.from(schema.users)
|
||||
.where(isNotNull(schema.users.avatar))
|
||||
.all();
|
||||
for (const row of avatarRows) {
|
||||
if (row.avatar) referenced.add(path.basename(row.avatar));
|
||||
}
|
||||
|
||||
// User banners
|
||||
const bannerRows = db.select({ banner: schema.users.banner })
|
||||
.from(schema.users)
|
||||
.where(isNotNull(schema.users.banner))
|
||||
.all();
|
||||
for (const row of bannerRows) {
|
||||
if (row.banner) referenced.add(path.basename(row.banner));
|
||||
}
|
||||
|
||||
// Space icons
|
||||
const iconRows = db.select({ icon: schema.spaces.icon })
|
||||
.from(schema.spaces)
|
||||
.where(isNotNull(schema.spaces.icon))
|
||||
.all();
|
||||
for (const row of iconRows) {
|
||||
if (row.icon) referenced.add(path.basename(row.icon));
|
||||
}
|
||||
|
||||
// Space banners
|
||||
const spaceBannerRows = db.select({ banner: schema.spaces.banner })
|
||||
.from(schema.spaces)
|
||||
.where(isNotNull(schema.spaces.banner))
|
||||
.all();
|
||||
for (const row of spaceBannerRows) {
|
||||
if (row.banner) referenced.add(path.basename(row.banner));
|
||||
}
|
||||
|
||||
return referenced;
|
||||
}
|
||||
|
||||
function getUnlinkedAttachments(): { id: string; filename: string; thumbnailFilename: string | null; size: number }[] {
|
||||
const db = getDb();
|
||||
const cutoff = Date.now() - UNLINKED_AGE_MS;
|
||||
// Attachments with no message_id AND no dm_message_id, older than 1 hour
|
||||
const rows = db.select({
|
||||
id: schema.attachments.id,
|
||||
filename: schema.attachments.filename,
|
||||
thumbnailFilename: schema.attachments.thumbnailFilename,
|
||||
size: schema.attachments.size,
|
||||
messageId: schema.attachments.messageId,
|
||||
dmMessageId: schema.attachments.dmMessageId,
|
||||
createdAt: schema.attachments.createdAt,
|
||||
}).from(schema.attachments).all();
|
||||
|
||||
return rows.filter(r =>
|
||||
r.messageId === null && r.dmMessageId === null && r.createdAt < cutoff
|
||||
).map(r => ({
|
||||
id: r.id,
|
||||
filename: r.filename,
|
||||
thumbnailFilename: r.thumbnailFilename,
|
||||
size: r.size,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getStorageStats(): StorageStats {
|
||||
const diskFiles = getDiskFiles();
|
||||
const referenced = getReferencedFilenames();
|
||||
const unlinked = getUnlinkedAttachments();
|
||||
|
||||
let totalSize = 0;
|
||||
let referencedSize = 0;
|
||||
let orphanedFiles = 0;
|
||||
let orphanedSize = 0;
|
||||
const breakdownMap = new Map<string, { count: number; size: number }>();
|
||||
|
||||
for (const file of diskFiles) {
|
||||
totalSize += file.size;
|
||||
const type = classifyFile(file.filename);
|
||||
const entry = breakdownMap.get(type) || { count: 0, size: 0 };
|
||||
entry.count++;
|
||||
entry.size += file.size;
|
||||
breakdownMap.set(type, entry);
|
||||
|
||||
if (referenced.has(file.filename)) {
|
||||
referencedSize += file.size;
|
||||
} else {
|
||||
orphanedFiles++;
|
||||
orphanedSize += file.size;
|
||||
}
|
||||
}
|
||||
|
||||
let unlinkedSize = 0;
|
||||
for (const att of unlinked) {
|
||||
unlinkedSize += att.size;
|
||||
}
|
||||
|
||||
const breakdown: StorageBreakdown[] = [];
|
||||
for (const [type, data] of breakdownMap) {
|
||||
breakdown.push({ type, count: data.count, size: data.size });
|
||||
}
|
||||
breakdown.sort((a, b) => b.size - a.size);
|
||||
|
||||
return {
|
||||
totalFiles: diskFiles.length,
|
||||
totalSize,
|
||||
referencedFiles: diskFiles.length - orphanedFiles,
|
||||
referencedSize,
|
||||
orphanedFiles,
|
||||
orphanedSize,
|
||||
unlinkedAttachments: unlinked.length,
|
||||
unlinkedSize,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrphanedFiles(): OrphanedFile[] {
|
||||
const diskFiles = getDiskFiles();
|
||||
const referenced = getReferencedFilenames();
|
||||
|
||||
const orphans: OrphanedFile[] = [];
|
||||
for (const file of diskFiles) {
|
||||
if (!referenced.has(file.filename)) {
|
||||
orphans.push({
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
orphans.sort((a, b) => b.size - a.size);
|
||||
return orphans;
|
||||
}
|
||||
|
||||
export function cleanupStorage(dryRun: boolean): CleanupResult {
|
||||
const db = getDb();
|
||||
const orphans = getOrphanedFiles();
|
||||
const unlinked = getUnlinkedAttachments();
|
||||
const errors: string[] = [];
|
||||
let deletedFiles = 0;
|
||||
let freedBytes = 0;
|
||||
let deletedAttachmentRecords = 0;
|
||||
|
||||
// Delete orphaned disk files
|
||||
for (const orphan of orphans) {
|
||||
if (!dryRun) {
|
||||
try {
|
||||
deleteUploadFile(orphan.filename);
|
||||
} catch (err: any) {
|
||||
errors.push(`Failed to delete ${orphan.filename}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
deletedFiles++;
|
||||
freedBytes += orphan.size;
|
||||
}
|
||||
|
||||
// Delete stale unlinked attachment records (and their disk files)
|
||||
for (const att of unlinked) {
|
||||
if (!dryRun) {
|
||||
try {
|
||||
deleteUploadFile(att.filename);
|
||||
if (att.thumbnailFilename) {
|
||||
deleteUploadFile(att.thumbnailFilename);
|
||||
}
|
||||
db.delete(schema.attachments)
|
||||
.where(eq(schema.attachments.id, att.id))
|
||||
.run();
|
||||
} catch (err: any) {
|
||||
errors.push(`Failed to clean up attachment ${att.id}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
deletedAttachmentRecords++;
|
||||
freedBytes += att.size;
|
||||
}
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
deletedFiles,
|
||||
freedBytes,
|
||||
deletedAttachmentRecords,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user