mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: added logging system
This commit is contained in:
+87
-12
@@ -2,6 +2,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../services/audit-events';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
@@ -227,14 +228,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse('Registration is temporarily unavailable, retry once', 409);
|
||||
}
|
||||
await storage.setRegistered();
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'user.register.first_admin',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: JSON.stringify({ email: user.email }),
|
||||
createdAt: now,
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
metadata: { email: user.email, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, role: user.role }, 200);
|
||||
}
|
||||
@@ -259,14 +260,14 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
return errorResponse('Invite code is invalid or expired', 403);
|
||||
}
|
||||
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'user.register.invite',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: JSON.stringify({ email: user.email, inviteCode }),
|
||||
createdAt: now,
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
metadata: { email: user.email, inviteCode, ...auditRequestMetadata(request) },
|
||||
});
|
||||
|
||||
return jsonResponse({ success: true, role: user.role }, 200);
|
||||
@@ -378,6 +379,18 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
||||
user.masterPasswordHint = masterPasswordHint;
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.profile.update',
|
||||
category: 'security',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
updatedMasterPasswordHint: true,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse(toProfile(user, env));
|
||||
}
|
||||
@@ -412,6 +425,18 @@ export async function handleSetVerifyDevices(request: Request, env: Env, userId:
|
||||
user.verifyDevices = body.verifyDevices;
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.verify_devices.update',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
verifyDevices: user.verifyDevices,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -461,6 +486,20 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveUser(user);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.keys.update',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
updatedKey: !!body.key,
|
||||
updatedPrivateKey: !!body.encryptedPrivateKey,
|
||||
updatedPublicKey: !!body.publicKey,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
return handleGetProfile(request, env, userId);
|
||||
}
|
||||
@@ -527,14 +566,14 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'user.password.change',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: JSON.stringify({ email: user.email }),
|
||||
createdAt: user.updatedAt,
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
metadata: { email: user.email, ...auditRequestMetadata(request) },
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
@@ -589,6 +628,15 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.totp.enable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
|
||||
}
|
||||
|
||||
@@ -604,6 +652,15 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.totp.disable',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
return jsonResponse({ enabled: false, object: 'twoFactor' });
|
||||
}
|
||||
|
||||
@@ -713,6 +770,15 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await rateLimit.clearLoginAttempts(recoverLimitKey);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'account.totp.recover',
|
||||
category: 'security',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
@@ -806,6 +872,15 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(user.id);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: user.id,
|
||||
action: rotate ? 'account.api_key.rotate' : 'account.api_key.create',
|
||||
category: 'security',
|
||||
level: rotate ? 'security' : 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: auditRequestMetadata(request),
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
|
||||
+117
-13
@@ -2,8 +2,8 @@ import { Env, User, Invite } from '../types';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||
import { auditRequestMetadata, getAuditLogSettings, normalizeAuditLogSettings, saveAuditLogSettings, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
@@ -25,16 +25,20 @@ async function writeAuditLog(
|
||||
action: string,
|
||||
targetType: string | null,
|
||||
targetId: string | null,
|
||||
metadata: Record<string, unknown> | null
|
||||
metadata: Record<string, unknown> | null,
|
||||
request?: Request
|
||||
): Promise<void> {
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
category: action.startsWith('admin.user.') ? 'security' : 'system',
|
||||
level: action.startsWith('admin.user.') ? 'security' : 'info',
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
...(request ? auditRequestMetadata(request) : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +86,106 @@ export async function handleAdminListUsers(
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/logs
|
||||
export async function handleAdminListAuditLogs(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
|
||||
const offset = Math.max(0, Number(url.searchParams.get('offset') || 0));
|
||||
const category = String(url.searchParams.get('category') || '').trim() || null;
|
||||
const level = String(url.searchParams.get('level') || '').trim() || null;
|
||||
const q = String(url.searchParams.get('q') || '').trim().toLowerCase() || null;
|
||||
const from = String(url.searchParams.get('from') || '').trim() || null;
|
||||
const to = String(url.searchParams.get('to') || '').trim() || null;
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const result = await storage.listAuditLogs({ limit, offset, category, level, q, from, to });
|
||||
return jsonResponse({
|
||||
data: result.logs.map(log => ({
|
||||
id: log.id,
|
||||
actorUserId: log.actorUserId,
|
||||
actorEmail: log.actorEmail,
|
||||
action: log.action,
|
||||
category: log.category,
|
||||
level: log.level,
|
||||
targetType: log.targetType,
|
||||
targetId: log.targetId,
|
||||
targetUserEmail: log.targetUserEmail,
|
||||
metadata: log.metadata,
|
||||
createdAt: log.createdAt,
|
||||
object: 'auditLog',
|
||||
})),
|
||||
total: result.total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: result.hasMore,
|
||||
object: 'list',
|
||||
continuationToken: result.hasMore ? String(offset + result.logs.length) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/admin/logs/settings
|
||||
export async function handleAdminGetAuditLogSettings(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
return jsonResponse({
|
||||
object: 'auditLogSettings',
|
||||
...await getAuditLogSettings(storage),
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/admin/logs/settings
|
||||
export async function handleAdminUpdateAuditLogSettings(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return errorResponse('Invalid JSON', 400);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const settings = await saveAuditLogSettings(storage, normalizeAuditLogSettings(body));
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.audit.settings.update', 'auditLog', null, { ...settings }, request);
|
||||
return jsonResponse({
|
||||
object: 'auditLogSettings',
|
||||
...settings,
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /api/admin/logs
|
||||
export async function handleAdminClearAuditLogs(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
const storage = new StorageService(env.DB);
|
||||
const deleted = await storage.clearAuditLogs();
|
||||
return jsonResponse({ object: 'auditLogClear', deleted });
|
||||
}
|
||||
|
||||
// POST /api/admin/invites
|
||||
export async function handleAdminCreateInvite(
|
||||
request: Request,
|
||||
@@ -116,9 +220,9 @@ export async function handleAdminCreateInvite(
|
||||
};
|
||||
|
||||
await storage.createInvite(invite);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', invite.code, {
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.create', 'invite', null, {
|
||||
expiresInHours,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return jsonResponse(toInviteResponse(request, invite), 201);
|
||||
}
|
||||
@@ -161,7 +265,7 @@ export async function handleAdminRevokeInvite(
|
||||
return errorResponse('Invite not found or already inactive', 404);
|
||||
}
|
||||
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', code, null);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -180,7 +284,7 @@ export async function handleAdminDeleteAllInvites(
|
||||
const deleted = await storage.deleteAllInvites();
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
|
||||
deleted,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return jsonResponse({ deleted }, 200);
|
||||
}
|
||||
@@ -226,7 +330,7 @@ export async function handleAdminSetUserStatus(
|
||||
AuthService.invalidateUserCache(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.status', 'user', target.id, {
|
||||
status: nextStatus,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return jsonResponse({
|
||||
id: target.id,
|
||||
@@ -284,8 +388,8 @@ export async function handleAdminDeleteUser(
|
||||
await storage.deleteUserById(target.id);
|
||||
AuthService.invalidateUserCache(target.id);
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
|
||||
email: target.email,
|
||||
});
|
||||
targetEmail: target.email,
|
||||
}, request);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getBlobStorageMaxBytes,
|
||||
putBlobObject,
|
||||
} from '../services/blob-store';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
@@ -30,6 +31,27 @@ function notifyVaultSyncForRequest(
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
async function writeAttachmentAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'attachment',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Format file size to human readable
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
@@ -430,6 +452,11 @@ export async function handleDeleteAttachment(
|
||||
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||
if (revisionInfo) {
|
||||
notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||
await writeAttachmentAudit(storage, request, revisionInfo.userId, 'attachment.delete', {
|
||||
id: attachmentId,
|
||||
cipherId,
|
||||
size: attachment.size,
|
||||
});
|
||||
}
|
||||
|
||||
// Get updated cipher for response
|
||||
|
||||
+22
-13
@@ -40,6 +40,7 @@ import {
|
||||
uploadBackupArchive,
|
||||
} from '../services/backup-uploader';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { getBlobObject } from '../services/blob-store';
|
||||
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||
|
||||
@@ -53,16 +54,20 @@ async function writeAuditLog(
|
||||
action: string,
|
||||
targetType: string | null,
|
||||
targetId: string | null,
|
||||
metadata: Record<string, unknown> | null
|
||||
metadata: Record<string, unknown> | null,
|
||||
request?: Request
|
||||
): Promise<void> {
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
category: 'data',
|
||||
level: action.endsWith('.failed') ? 'error' : 'info',
|
||||
metadata: {
|
||||
...(metadata || {}),
|
||||
...(request ? auditRequestMetadata(request) : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,7 +272,8 @@ async function executeConfiguredBackup(
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}) => Promise<void>) | null
|
||||
}) => Promise<void>) | null,
|
||||
auditMetadata?: Record<string, unknown> | null
|
||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||
const maxArchiveUploadAttempts = 3;
|
||||
const touchLease = async () => {
|
||||
@@ -423,6 +429,7 @@ async function executeConfiguredBackup(
|
||||
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||
prunedFileCount,
|
||||
pruneError: pruneErrorMessage,
|
||||
...(auditMetadata || {}),
|
||||
});
|
||||
|
||||
await progress?.({
|
||||
@@ -451,6 +458,7 @@ async function executeConfiguredBackup(
|
||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||
...getBackupDestinationSummary(destination),
|
||||
error: destination.runtime.lastErrorMessage,
|
||||
...(auditMetadata || {}),
|
||||
});
|
||||
await progress?.({
|
||||
operation: 'backup-remote-run',
|
||||
@@ -513,7 +521,7 @@ async function runImportAndAudit(
|
||||
skippedReason: imported.result.skipped.reason,
|
||||
replaceExisting,
|
||||
...metadata,
|
||||
});
|
||||
}, request);
|
||||
return imported;
|
||||
}
|
||||
|
||||
@@ -586,7 +594,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
||||
destinationCount: next.destinations.length,
|
||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||
});
|
||||
}, request);
|
||||
return jsonResponse(next);
|
||||
}
|
||||
|
||||
@@ -636,7 +644,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
||||
destinationCount: next.destinations.length,
|
||||
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||
});
|
||||
}, request);
|
||||
return jsonResponse(next);
|
||||
}
|
||||
|
||||
@@ -675,7 +683,8 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
||||
'manual',
|
||||
body?.destinationId || null,
|
||||
keepAlive,
|
||||
progress
|
||||
progress,
|
||||
auditRequestMetadata(request)
|
||||
);
|
||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||
return { result, settings };
|
||||
@@ -777,7 +786,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||
...getBackupDestinationSummary(destination),
|
||||
remotePath: path,
|
||||
});
|
||||
}, request);
|
||||
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
||||
@@ -860,7 +869,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
||||
bytes: remoteFile.bytes.byteLength,
|
||||
trigger: 'remote',
|
||||
checksumMismatchAccepted: !checksumOk,
|
||||
});
|
||||
}, request);
|
||||
return result;
|
||||
})();
|
||||
return jsonResponse(imported.result);
|
||||
@@ -937,7 +946,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
||||
attachments: archive.manifest.tableCounts.attachments,
|
||||
compressedBytes: archive.bytes.byteLength,
|
||||
includesAttachments: archive.manifest.includes.attachments,
|
||||
});
|
||||
}, request);
|
||||
|
||||
return new Response(archive.bytes, {
|
||||
status: 200,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { generateUUID } from '../utils/uuid';
|
||||
import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
// CONTRACT:
|
||||
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
|
||||
@@ -83,6 +84,27 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||
return cipher;
|
||||
}
|
||||
|
||||
async function writeCipherAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'cipher',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isValidEncString(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
@@ -584,6 +606,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', {
|
||||
id: cipher.id,
|
||||
type: cipher.type,
|
||||
folderId: cipher.folderId ?? null,
|
||||
});
|
||||
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [])
|
||||
@@ -608,6 +635,12 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
||||
await storage.deleteCipher(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||
id,
|
||||
type: cipher.type,
|
||||
folderId: cipher.folderId ?? null,
|
||||
compat: true,
|
||||
});
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -629,6 +662,11 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
||||
await storage.deleteCipher(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', {
|
||||
id,
|
||||
type: cipher.type,
|
||||
folderId: cipher.folderId ?? null,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -858,6 +896,9 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
|
||||
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', {
|
||||
count: body.ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -917,6 +958,10 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En
|
||||
const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', {
|
||||
count: ownedIds.length,
|
||||
requestedCount: ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceR
|
||||
import { Env } from '../types';
|
||||
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||
import { AuthService } from '../services/auth';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
@@ -268,6 +269,15 @@ export async function handleRevokeTrustedDevice(
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.revoke',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { removed, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removed });
|
||||
}
|
||||
|
||||
@@ -286,6 +296,15 @@ export async function handleTrustDevicePermanently(
|
||||
const storage = new StorageService(env.DB);
|
||||
const updated = await storage.updateTrustedTwoFactorTokensExpiryByDevice(userId, normalized, PERMANENT_TRUST_EXPIRES_AT_MS);
|
||||
if (!updated) return errorResponse('Device is not currently trusted', 409);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.permanent',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { updated, ...auditRequestMetadata(request) },
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
@@ -313,6 +332,15 @@ export async function handleDeleteDevice(
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.delete',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
@@ -336,6 +364,15 @@ export async function handleUpdateDeviceName(
|
||||
|
||||
const device = await storage.getDevice(userId, normalized);
|
||||
if (!device) return errorResponse('Device not found', 404);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.name.update',
|
||||
category: 'device',
|
||||
level: 'info',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { name, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse(buildDeviceResponse(device));
|
||||
}
|
||||
|
||||
@@ -356,6 +393,15 @@ export async function handleDeleteAllDevices(request: Request, env: Env, userId:
|
||||
await storage.saveUser(user);
|
||||
AuthService.invalidateUserCache(userId);
|
||||
notifyUserLogout(env, userId, null);
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.delete_all',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
metadata: { removedTrusted, removedSessions, removedDevices, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||
}
|
||||
|
||||
@@ -447,6 +493,15 @@ export async function handleUntrustDevices(
|
||||
if (!deviceIdentifier) continue;
|
||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.trust.revoke_batch',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'user',
|
||||
targetId: userId,
|
||||
metadata: { requested: devices.length, removed, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: true, removed });
|
||||
}
|
||||
|
||||
@@ -489,6 +544,15 @@ export async function handleDeactivateDevice(
|
||||
AuthService.invalidateDeviceCache(userId, normalized);
|
||||
notifyUserLogout(env, userId, normalized);
|
||||
}
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action: 'device.deactivate',
|
||||
category: 'device',
|
||||
level: 'security',
|
||||
targetType: 'device',
|
||||
targetId: normalized,
|
||||
metadata: { deleted, ...auditRequestMetadata(request) },
|
||||
});
|
||||
return jsonResponse({ success: deleted });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
function notifyVaultSyncForRequest(
|
||||
request: Request,
|
||||
@@ -15,6 +16,27 @@ function notifyVaultSyncForRequest(
|
||||
notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||
}
|
||||
|
||||
async function writeFolderAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'folder',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Convert internal folder to API response format
|
||||
function folderToResponse(folder: Folder): FolderResponse {
|
||||
return {
|
||||
@@ -134,6 +156,9 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
||||
await storage.deleteFolder(id, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeFolderAudit(storage, request, userId, 'folder.delete', {
|
||||
id,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
@@ -157,6 +182,9 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
|
||||
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', {
|
||||
count: ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
@@ -251,11 +252,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.user_inactive',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
|
||||
if (!valid) {
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.bad_password',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return recordFailedLoginAndBuildResponse(
|
||||
rateLimit,
|
||||
loginIdentifier,
|
||||
@@ -349,6 +376,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.success',
|
||||
category: 'auth',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
webSession: shouldUseWebSession(request),
|
||||
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
@@ -412,11 +454,37 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
}
|
||||
if (user.status !== 'active') {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.user_inactive',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) {
|
||||
await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.failed.bad_api_key',
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
deviceIdentifier: deviceInfo.deviceIdentifier,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
return identityErrorResponse('ClientId or clientSecret is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
@@ -439,6 +507,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: user.id,
|
||||
action: 'auth.login.success',
|
||||
category: 'auth',
|
||||
level: 'info',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
metadata: {
|
||||
grantType,
|
||||
webSession: shouldUseWebSession(request),
|
||||
deviceIdentifier: deviceSession?.identifier ?? deviceInfo.deviceIdentifier,
|
||||
deviceType: deviceInfo.deviceType,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
@@ -543,8 +626,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||
}
|
||||
|
||||
const result = await auth.refreshAccessToken(refreshToken);
|
||||
if (!result) {
|
||||
const result = await auth.refreshAccessTokenDetailed(refreshToken);
|
||||
if (!result.ok) {
|
||||
await safeWriteAuditEvent(env, {
|
||||
actorUserId: result.userId ?? null,
|
||||
action: `auth.refresh.failed.${result.reason}`,
|
||||
category: 'auth',
|
||||
level: 'warn',
|
||||
targetType: result.deviceIdentifier ? 'device' : 'refreshToken',
|
||||
targetId: result.deviceIdentifier ?? null,
|
||||
metadata: {
|
||||
grantType,
|
||||
reason: result.reason,
|
||||
webSession: shouldUseWebSession(request),
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||
return shouldUseWebSession(request)
|
||||
? withWebRefreshCookie(request, invalidResponse, null)
|
||||
|
||||
@@ -29,6 +29,28 @@ import {
|
||||
setSendPassword,
|
||||
validateDeletionDate,
|
||||
} from './sends-shared';
|
||||
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
||||
|
||||
async function writeSendAudit(
|
||||
storage: StorageService,
|
||||
request: Request,
|
||||
userId: string,
|
||||
action: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await writeAuditEvent(storage, {
|
||||
actorUserId: userId,
|
||||
action,
|
||||
category: 'data',
|
||||
level: action.includes('delete') ? 'security' : 'info',
|
||||
targetType: 'send',
|
||||
targetId: typeof metadata.id === 'string' ? metadata.id : null,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...auditRequestMetadata(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function processSendFileUpload(
|
||||
request: Request,
|
||||
@@ -602,7 +624,6 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
|
||||
}
|
||||
|
||||
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
@@ -620,6 +641,10 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin
|
||||
await storage.deleteSend(sendId, userId);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.delete', {
|
||||
id: sendId,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -651,13 +676,16 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
|
||||
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||
if (revisionDate) {
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.delete.bulk', {
|
||||
count: sends.length,
|
||||
requestedCount: body.ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
@@ -669,12 +697,15 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.password.remove', {
|
||||
id: send.id,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const send = await storage.getSend(sendId);
|
||||
if (!send || send.userId !== userId) {
|
||||
@@ -687,6 +718,10 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s
|
||||
await storage.saveSend(send);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
await writeSendAudit(storage, request, userId, 'send.auth.remove', {
|
||||
id: send.id,
|
||||
type: send.type,
|
||||
});
|
||||
|
||||
return jsonResponse(sendToResponse(send));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user