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:
@@ -22,6 +22,7 @@ import { utilRoutes } from './routes/utils.js';
|
||||
import { instanceRoutes } from './routes/instance.js';
|
||||
import { exploreRoutes } from './routes/explore.js';
|
||||
import { searchRoutes } from './routes/search.js';
|
||||
import { adminRoutes } from './routes/admin.js';
|
||||
import { registerWebSocket } from './ws/handler.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -88,6 +89,7 @@ async function main(): Promise<void> {
|
||||
await app.register(instanceRoutes);
|
||||
await app.register(exploreRoutes);
|
||||
await app.register(searchRoutes);
|
||||
await app.register(adminRoutes);
|
||||
await app.register(registerWebSocket);
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import crypto from 'crypto';
|
||||
import { eq, like, or, and, ne, sql } from 'drizzle-orm';
|
||||
import { authenticate, requireAdmin, hashPassword } from '../utils/auth.js';
|
||||
import { getStorageStats, getOrphanedFiles, cleanupStorage } from '../utils/storageJanitor.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import type { AdminUser, AdminUserListResponse, AdminResetPasswordResponse } from '@backspace/shared';
|
||||
|
||||
function toAdminUser(row: typeof schema.users.$inferSelect): AdminUser {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
avatar: row.avatar,
|
||||
avatarColor: row.avatarColor,
|
||||
status: row.status ?? 'offline',
|
||||
isAdmin: row.isAdmin === 1,
|
||||
isDeleted: row.isDeleted === 1,
|
||||
homeInstance: row.homeInstance,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── Storage Management ──────────────────────────────────────────────────
|
||||
|
||||
// GET /api/admin/storage/stats — storage overview
|
||||
app.get('/api/admin/storage/stats', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
|
||||
try {
|
||||
const stats = getStorageStats();
|
||||
return reply.code(200).send(stats);
|
||||
} catch (err: any) {
|
||||
return reply.code(500).send({ error: `Failed to compute storage stats: ${err.message}`, statusCode: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/storage/orphans — list orphaned files
|
||||
app.get('/api/admin/storage/orphans', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
|
||||
try {
|
||||
const orphans = getOrphanedFiles();
|
||||
return reply.code(200).send({ orphans });
|
||||
} catch (err: any) {
|
||||
return reply.code(500).send({ error: `Failed to list orphaned files: ${err.message}`, statusCode: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/storage/cleanup — delete orphaned files
|
||||
app.post<{ Body: { dryRun?: boolean } }>('/api/admin/storage/cleanup', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const dryRun = request.body?.dryRun ?? false;
|
||||
const result = cleanupStorage(dryRun);
|
||||
return reply.code(200).send(result);
|
||||
} catch (err: any) {
|
||||
return reply.code(500).send({ error: `Cleanup failed: ${err.message}`, statusCode: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── User Management ────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/admin/users — paginated user list with search
|
||||
app.get<{ Querystring: { q?: string; page?: string; pageSize?: string; showDeleted?: string } }>(
|
||||
'/api/admin/users',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
const q = request.query.q?.trim() || '';
|
||||
const page = Math.max(1, parseInt(request.query.page || '1', 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(request.query.pageSize || '50', 10) || 50));
|
||||
const showDeleted = request.query.showDeleted === 'true';
|
||||
|
||||
const conditions = [];
|
||||
if (!showDeleted) {
|
||||
conditions.push(eq(schema.users.isDeleted, 0));
|
||||
}
|
||||
if (q) {
|
||||
const pattern = `%${q}%`;
|
||||
conditions.push(or(
|
||||
like(schema.users.username, pattern),
|
||||
like(schema.users.displayName, pattern),
|
||||
)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const countResult = db.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.users)
|
||||
.where(where)
|
||||
.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
const rows = db.select()
|
||||
.from(schema.users)
|
||||
.where(where)
|
||||
.orderBy(sql`${schema.users.createdAt} DESC`)
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize)
|
||||
.all();
|
||||
|
||||
const response: AdminUserListResponse = {
|
||||
users: rows.map(toAdminUser),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
|
||||
return reply.code(200).send(response);
|
||||
},
|
||||
);
|
||||
|
||||
// PATCH /api/admin/users/:id/role — promote/demote admin
|
||||
app.patch<{ Params: { id: string }; Body: { isAdmin: boolean } }>(
|
||||
'/api/admin/users/:id/role',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id: targetId } = request.params;
|
||||
const { isAdmin } = request.body;
|
||||
|
||||
if (typeof isAdmin !== 'boolean') {
|
||||
return reply.code(400).send({ error: 'isAdmin must be a boolean', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
}
|
||||
if (target.isDeleted === 1) {
|
||||
return reply.code(400).send({ error: 'Cannot change role of a deleted user', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Promote: block federated users
|
||||
if (isAdmin && target.homeInstance) {
|
||||
return reply.code(403).send({ error: 'Federated users cannot be promoted to admin', statusCode: 403 });
|
||||
}
|
||||
|
||||
// Demote: prevent removing the last admin
|
||||
if (!isAdmin && target.isAdmin === 1) {
|
||||
const adminCount = db.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.users)
|
||||
.where(and(eq(schema.users.isAdmin, 1), eq(schema.users.isDeleted, 0)))
|
||||
.get();
|
||||
if ((adminCount?.count ?? 0) <= 1) {
|
||||
return reply.code(400).send({ error: 'Cannot demote the last admin', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
db.update(schema.users)
|
||||
.set({ isAdmin: isAdmin ? 1 : 0 })
|
||||
.where(eq(schema.users.id, targetId))
|
||||
.run();
|
||||
|
||||
const updated = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get()!;
|
||||
|
||||
// Broadcast user_updated so the target's UI reflects the isAdmin change
|
||||
connectionManager.sendToUser(targetId, {
|
||||
type: 'user_updated',
|
||||
user: sanitizeUser(updated),
|
||||
});
|
||||
|
||||
return reply.code(200).send(toAdminUser(updated));
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — generate temporary password
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/admin/users/:id/reset-password',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id: targetId } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
}
|
||||
if (target.isDeleted === 1) {
|
||||
return reply.code(400).send({ error: 'Cannot reset password of a deleted user', statusCode: 400 });
|
||||
}
|
||||
if (target.homeInstance) {
|
||||
return reply.code(400).send({ error: 'Federated users authenticate via their home instance', statusCode: 400 });
|
||||
}
|
||||
|
||||
const temporaryPassword = crypto.randomBytes(12).toString('base64url');
|
||||
const hash = await hashPassword(temporaryPassword);
|
||||
|
||||
db.update(schema.users)
|
||||
.set({ passwordHash: hash })
|
||||
.where(eq(schema.users.id, targetId))
|
||||
.run();
|
||||
|
||||
// Force re-auth by disconnecting all sessions
|
||||
connectionManager.forceDisconnectUser(targetId);
|
||||
|
||||
const response: AdminResetPasswordResponse = { temporaryPassword };
|
||||
return reply.code(200).send(response);
|
||||
},
|
||||
);
|
||||
|
||||
// DELETE /api/admin/users/:id — tombstone a user account
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/admin/users/:id',
|
||||
{ preHandler: [authenticate, requireAdmin] },
|
||||
async (request, reply) => {
|
||||
const { id: targetId } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
if (targetId === request.userId) {
|
||||
return reply.code(400).send({ error: 'Use account settings to delete your own account', statusCode: 400 });
|
||||
}
|
||||
|
||||
const target = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get();
|
||||
if (!target) {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
}
|
||||
if (target.isDeleted === 1) {
|
||||
return reply.code(400).send({ error: 'User is already deleted', statusCode: 400 });
|
||||
}
|
||||
|
||||
// Check if user owns any spaces
|
||||
const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name })
|
||||
.from(schema.spaces)
|
||||
.where(eq(schema.spaces.ownerId, targetId))
|
||||
.all();
|
||||
if (ownedSpaces.length > 0) {
|
||||
return reply.code(400).send({
|
||||
error: 'User owns spaces — transfer ownership first',
|
||||
statusCode: 400,
|
||||
ownedSpaces,
|
||||
});
|
||||
}
|
||||
|
||||
const filesToDelete = tombstoneUser(targetId);
|
||||
for (const filename of filesToDelete) {
|
||||
deleteUploadFile(filename);
|
||||
}
|
||||
|
||||
connectionManager.forceDisconnectUser(targetId);
|
||||
|
||||
return reply.code(200).send({ success: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { authenticate, requireAdmin } from '../utils/auth.js';
|
||||
import { config } from '../config.js';
|
||||
import type { InstanceStreamingLimits, InstanceAdminSettings } from '@backspace/shared';
|
||||
|
||||
@@ -33,15 +33,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
// PATCH /api/settings/streaming — admin only
|
||||
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => {
|
||||
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// Verify caller is an instance admin
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can modify streaming settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
|
||||
|
||||
@@ -129,14 +123,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
// GET /api/settings/instance — admin only, returns instance admin settings
|
||||
app.get('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
|
||||
app.get('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (_request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can view instance settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
|
||||
if (!row) {
|
||||
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
|
||||
@@ -152,14 +141,9 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
|
||||
// PATCH /api/settings/instance — admin only, updates instance admin settings
|
||||
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: authenticate }, async (request, reply) => {
|
||||
app.patch<{ Body: Partial<InstanceAdminSettings> }>('/api/settings/instance', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
return reply.code(403).send({ error: 'Only instance admins can modify instance settings', statusCode: 403 });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, or, and, inArray } from 'drizzle-orm';
|
||||
import crypto from 'crypto';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate, verifyPassword, hashPassword, signJwt } from '../utils/auth.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type { UpdateUserRequest, VerifyPasswordRequest, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, ReplicatedInstance, SpaceLayoutItem, SpaceFolder } from '@backspace/shared';
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
import { deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
@@ -125,123 +125,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const uid = request.userId;
|
||||
|
||||
// Collect file references before the transaction (avatar, banner)
|
||||
const filesToDelete: string[] = [];
|
||||
if (user.avatar) filesToDelete.push(user.avatar);
|
||||
if (user.banner) filesToDelete.push(user.banner);
|
||||
|
||||
// Find group DMs this user owns so we can transfer ownership
|
||||
const ownedGroupDms = db.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.ownerId, uid))
|
||||
.all();
|
||||
|
||||
// Run all cleanup in a single transaction
|
||||
db.transaction((tx) => {
|
||||
// Remove from spaces, roles, friends, DMs, read states, reactions, folders, bans, join requests, voice restrictions, channel overrides
|
||||
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run();
|
||||
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
|
||||
tx.delete(schema.friends).where(or(eq(schema.friends.userId, uid), eq(schema.friends.friendId, uid))).run();
|
||||
tx.delete(schema.friendRequests).where(or(eq(schema.friendRequests.fromId, uid), eq(schema.friendRequests.toId, uid))).run();
|
||||
tx.delete(schema.dmMembers).where(eq(schema.dmMembers.userId, uid)).run();
|
||||
tx.delete(schema.readStates).where(eq(schema.readStates.userId, uid)).run();
|
||||
tx.delete(schema.reactions).where(eq(schema.reactions.userId, uid)).run();
|
||||
tx.delete(schema.dmReactions).where(eq(schema.dmReactions.userId, uid)).run();
|
||||
tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run();
|
||||
|
||||
// Conditional deletes for tables that may reference userId
|
||||
try { tx.delete(schema.bans).where(eq(schema.bans.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
|
||||
// Nullify moderator references pointing to this user
|
||||
try {
|
||||
tx.update(schema.bans).set({ bannedBy: null }).where(eq(schema.bans.bannedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.voiceRestrictions).set({ moderatorId: null }).where(eq(schema.voiceRestrictions.moderatorId, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.joinRequests).set({ decidedBy: null }).where(eq(schema.joinRequests.decidedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
|
||||
// Remove member-type channel overrides for this user
|
||||
tx.delete(schema.channelOverrides).where(
|
||||
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
|
||||
).run();
|
||||
|
||||
// Transfer ownership of group DMs to the next remaining member
|
||||
for (const { id: dmId } of ownedGroupDms) {
|
||||
const nextMember = tx.select({ userId: schema.dmMembers.userId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmId))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (nextMember) {
|
||||
tx.update(schema.dmChannels)
|
||||
.set({ ownerId: nextMember.userId })
|
||||
.where(eq(schema.dmChannels.id, dmId))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned DM channels (zero members after our removal)
|
||||
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.all()
|
||||
.filter(dc => {
|
||||
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dc.id))
|
||||
.all()
|
||||
.length;
|
||||
return memberCount === 0;
|
||||
})
|
||||
.map(dc => dc.id);
|
||||
|
||||
for (const dmId of orphanedDmIds) {
|
||||
// Collect message IDs for this orphaned DM channel
|
||||
const msgIds = tx.select({ id: schema.dmMessages.id })
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, dmId))
|
||||
.all()
|
||||
.map(m => m.id);
|
||||
|
||||
if (msgIds.length > 0) {
|
||||
// Collect attachment filenames for cleanup after tx
|
||||
const dmAttachments = tx.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
for (const att of dmAttachments) filesToDelete.push(att.filename);
|
||||
|
||||
// Delete attachments + reactions for all messages in this DM channel
|
||||
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||
}
|
||||
// Delete the DM channel (cascades to dm_messages)
|
||||
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run();
|
||||
}
|
||||
|
||||
// Tombstone user row — rename username to free it for reuse
|
||||
tx.update(schema.users).set({
|
||||
username: `!deleted:${uid}`,
|
||||
passwordHash: crypto.randomBytes(32).toString('hex'), // unusable random string
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
banner: null,
|
||||
bio: null,
|
||||
customStatus: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
replicatedInstances: '[]',
|
||||
isDeleted: 1,
|
||||
status: 'offline',
|
||||
isAdmin: 0,
|
||||
}).where(eq(schema.users.id, uid)).run();
|
||||
});
|
||||
// Tombstone the account (transaction handles all DB cleanup)
|
||||
const filesToDelete = tombstoneUser(request.userId);
|
||||
|
||||
// Clean up files from disk after transaction commits
|
||||
for (const filename of filesToDelete) {
|
||||
@@ -569,6 +454,16 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
.set({ name: folder.name, color: folder.color })
|
||||
.where(and(eq(schema.spaceFolders.id, key), eq(schema.spaceFolders.userId, userId)))
|
||||
.run();
|
||||
} else {
|
||||
// Folder from a remote instance — create it locally with the original ID
|
||||
tx.insert(schema.spaceFolders).values({
|
||||
id: key,
|
||||
userId,
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
position: 0,
|
||||
createdAt: Date.now(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
// Clear and re-insert folder members with position
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
const SALT_ROUNDS = 12;
|
||||
@@ -50,6 +52,17 @@ export async function authenticate(
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAdmin(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!caller || caller.isAdmin !== 1) {
|
||||
reply.code(403).send({ error: 'Only instance admins can perform this action', statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
userId: string;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import crypto from 'crypto';
|
||||
import { eq, or, and, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* Tombstone a user account: removes them from all spaces, DMs, friends,
|
||||
* roles, reactions, folders, bans, voice restrictions, channel overrides,
|
||||
* transfers group DM ownership, cleans up orphaned DMs, and marks the
|
||||
* user row as deleted.
|
||||
*
|
||||
* Returns a list of filenames to delete from disk (avatar, banner,
|
||||
* orphaned DM attachments). The caller is responsible for disk cleanup
|
||||
* and WebSocket disconnection after calling this.
|
||||
*/
|
||||
export function tombstoneUser(uid: string): string[] {
|
||||
const db = getDb();
|
||||
|
||||
const user = db.select().from(schema.users).where(eq(schema.users.id, uid)).get();
|
||||
if (!user) return [];
|
||||
|
||||
const filesToDelete: string[] = [];
|
||||
if (user.avatar) filesToDelete.push(user.avatar);
|
||||
if (user.banner) filesToDelete.push(user.banner);
|
||||
|
||||
// Find group DMs this user owns so we can transfer ownership
|
||||
const ownedGroupDms = db.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.ownerId, uid))
|
||||
.all();
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Remove from spaces, roles, friends, DMs, read states, reactions, folders
|
||||
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.userId, uid)).run();
|
||||
tx.delete(schema.memberRoles).where(eq(schema.memberRoles.userId, uid)).run();
|
||||
tx.delete(schema.friends).where(or(eq(schema.friends.userId, uid), eq(schema.friends.friendId, uid))).run();
|
||||
tx.delete(schema.friendRequests).where(or(eq(schema.friendRequests.fromId, uid), eq(schema.friendRequests.toId, uid))).run();
|
||||
tx.delete(schema.dmMembers).where(eq(schema.dmMembers.userId, uid)).run();
|
||||
tx.delete(schema.readStates).where(eq(schema.readStates.userId, uid)).run();
|
||||
tx.delete(schema.reactions).where(eq(schema.reactions.userId, uid)).run();
|
||||
tx.delete(schema.dmReactions).where(eq(schema.dmReactions.userId, uid)).run();
|
||||
tx.delete(schema.spaceFolders).where(eq(schema.spaceFolders.userId, uid)).run();
|
||||
|
||||
// Conditional deletes for tables that may reference userId
|
||||
try { tx.delete(schema.bans).where(eq(schema.bans.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.joinRequests).where(eq(schema.joinRequests.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
try { tx.delete(schema.voiceRestrictions).where(eq(schema.voiceRestrictions.userId, uid)).run(); } catch { /* table may not exist */ }
|
||||
|
||||
// Nullify moderator references pointing to this user
|
||||
try {
|
||||
tx.update(schema.bans).set({ bannedBy: null }).where(eq(schema.bans.bannedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.voiceRestrictions).set({ moderatorId: null }).where(eq(schema.voiceRestrictions.moderatorId, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
try {
|
||||
tx.update(schema.joinRequests).set({ decidedBy: null }).where(eq(schema.joinRequests.decidedBy, uid)).run();
|
||||
} catch { /* table may not exist */ }
|
||||
|
||||
// Remove member-type channel overrides for this user
|
||||
tx.delete(schema.channelOverrides).where(
|
||||
and(eq(schema.channelOverrides.targetType, 'member'), eq(schema.channelOverrides.targetId, uid))
|
||||
).run();
|
||||
|
||||
// Transfer ownership of group DMs to the next remaining member
|
||||
for (const { id: dmId } of ownedGroupDms) {
|
||||
const nextMember = tx.select({ userId: schema.dmMembers.userId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dmId))
|
||||
.limit(1)
|
||||
.get();
|
||||
if (nextMember) {
|
||||
tx.update(schema.dmChannels)
|
||||
.set({ ownerId: nextMember.userId })
|
||||
.where(eq(schema.dmChannels.id, dmId))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned DM channels (zero members after our removal)
|
||||
const orphanedDmIds = tx.select({ id: schema.dmChannels.id })
|
||||
.from(schema.dmChannels)
|
||||
.all()
|
||||
.filter(dc => {
|
||||
const memberCount = tx.select({ id: schema.dmMembers.dmChannelId })
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, dc.id))
|
||||
.all()
|
||||
.length;
|
||||
return memberCount === 0;
|
||||
})
|
||||
.map(dc => dc.id);
|
||||
|
||||
for (const dmId of orphanedDmIds) {
|
||||
const msgIds = tx.select({ id: schema.dmMessages.id })
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, dmId))
|
||||
.all()
|
||||
.map(m => m.id);
|
||||
|
||||
if (msgIds.length > 0) {
|
||||
const dmAttachments = tx.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments)
|
||||
.where(inArray(schema.attachments.dmMessageId, msgIds))
|
||||
.all();
|
||||
for (const att of dmAttachments) filesToDelete.push(att.filename);
|
||||
|
||||
tx.delete(schema.attachments).where(inArray(schema.attachments.dmMessageId, msgIds)).run();
|
||||
tx.delete(schema.dmReactions).where(inArray(schema.dmReactions.dmMessageId, msgIds)).run();
|
||||
}
|
||||
tx.delete(schema.dmChannels).where(eq(schema.dmChannels.id, dmId)).run();
|
||||
}
|
||||
|
||||
// Tombstone user row — rename username to free it for reuse
|
||||
tx.update(schema.users).set({
|
||||
username: `!deleted:${uid}`,
|
||||
passwordHash: crypto.randomBytes(32).toString('hex'),
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
banner: null,
|
||||
bio: null,
|
||||
customStatus: null,
|
||||
accentColor: null,
|
||||
avatarColor: null,
|
||||
replicatedInstances: '[]',
|
||||
isDeleted: 1,
|
||||
status: 'offline',
|
||||
isAdmin: 0,
|
||||
}).where(eq(schema.users.id, uid)).run();
|
||||
});
|
||||
|
||||
return filesToDelete;
|
||||
}
|
||||
Reference in New Issue
Block a user