mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance attachment metadata handling and add change password URI support
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
verifyAttachmentUploadToken,
|
||||
verifyFileDownloadToken,
|
||||
} from '../utils/jwt';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { readActingDeviceIdentifier } from '../utils/device';
|
||||
import {
|
||||
@@ -282,6 +282,7 @@ export async function handleGetAttachment(
|
||||
if (!attachment || attachment.cipherId !== cipherId) {
|
||||
return errorResponse('Attachment not found', 404);
|
||||
}
|
||||
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
|
||||
|
||||
// Generate short-lived download token
|
||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||
@@ -292,12 +293,12 @@ export async function handleGetAttachment(
|
||||
|
||||
return jsonResponse({
|
||||
object: 'attachment',
|
||||
id: attachment.id,
|
||||
id: responseAttachment.id,
|
||||
url: downloadUrl,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key,
|
||||
size: String(Number(attachment.size) || 0),
|
||||
sizeName: attachment.sizeName,
|
||||
fileName: responseAttachment.fileName,
|
||||
key: responseAttachment.key,
|
||||
size: String(Number(responseAttachment.size) || 0),
|
||||
sizeName: responseAttachment.sizeName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+195
-2
@@ -263,6 +263,196 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
return formatted.length ? formatted : null;
|
||||
}
|
||||
|
||||
function formatAttachmentSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Bytes`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
interface IncomingAttachmentMetadata {
|
||||
id: string;
|
||||
fileName?: unknown;
|
||||
key?: unknown;
|
||||
fileSize?: unknown;
|
||||
hasFileName: boolean;
|
||||
hasKey: boolean;
|
||||
hasFileSize: boolean;
|
||||
}
|
||||
|
||||
function readIncomingAttachmentMetadataMap(
|
||||
value: unknown,
|
||||
options: { legacyFileNameMap?: boolean } = {}
|
||||
): IncomingAttachmentMetadata[] {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
const out: IncomingAttachmentMetadata[] = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = String(row.id ?? row.Id ?? '').trim();
|
||||
if (!id) continue;
|
||||
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
|
||||
const key = getAliasedProp(row, ['key', 'Key']);
|
||||
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
|
||||
out.push({
|
||||
id,
|
||||
fileName: fileName.value,
|
||||
key: key.value,
|
||||
fileSize: fileSize.value,
|
||||
hasFileName: fileName.present,
|
||||
hasKey: key.present,
|
||||
hasFileSize: fileSize.present,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const [rawId, rawValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
const id = String(rawId || '').trim();
|
||||
if (!id) continue;
|
||||
|
||||
if (options.legacyFileNameMap && (typeof rawValue === 'string' || rawValue == null)) {
|
||||
out.push({
|
||||
id,
|
||||
fileName: rawValue,
|
||||
key: undefined,
|
||||
fileSize: undefined,
|
||||
hasFileName: rawValue != null,
|
||||
hasKey: false,
|
||||
hasFileSize: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rawValue || typeof rawValue !== 'object') continue;
|
||||
const row = rawValue as Record<string, unknown>;
|
||||
const fileName = getAliasedProp(row, ['fileName', 'FileName']);
|
||||
const key = getAliasedProp(row, ['key', 'Key']);
|
||||
const fileSize = getAliasedProp(row, ['fileSize', 'FileSize', 'size', 'Size']);
|
||||
out.push({
|
||||
id,
|
||||
fileName: fileName.value,
|
||||
key: key.value,
|
||||
fileSize: fileSize.value,
|
||||
hasFileName: fileName.present,
|
||||
hasKey: key.present,
|
||||
hasFileSize: fileSize.present,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata[] {
|
||||
const merged = new Map<string, IncomingAttachmentMetadata>();
|
||||
const legacy = getAliasedProp(source, ['attachments', 'Attachments']);
|
||||
const current = getAliasedProp(source, ['attachments2', 'Attachments2']);
|
||||
|
||||
if (legacy.present) {
|
||||
for (const item of readIncomingAttachmentMetadataMap(legacy.value, { legacyFileNameMap: true })) {
|
||||
merged.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
if (current.present) {
|
||||
for (const item of readIncomingAttachmentMetadataMap(current.value)) {
|
||||
const previous = merged.get(item.id);
|
||||
merged.set(item.id, {
|
||||
id: item.id,
|
||||
fileName: item.hasFileName ? item.fileName : previous?.fileName,
|
||||
key: item.hasKey ? item.key : previous?.key,
|
||||
fileSize: item.hasFileSize ? item.fileSize : previous?.fileSize,
|
||||
hasFileName: item.hasFileName || previous?.hasFileName || false,
|
||||
hasKey: item.hasKey || previous?.hasKey || false,
|
||||
hasFileSize: item.hasFileSize || previous?.hasFileSize || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function hasIncomingAttachmentMetadata(source: any): boolean {
|
||||
return readIncomingAttachmentMetadata(source).length > 0;
|
||||
}
|
||||
|
||||
async function syncIncomingAttachmentMetadata(
|
||||
storage: StorageService,
|
||||
cipherId: string,
|
||||
cipherData: any
|
||||
): Promise<void> {
|
||||
const incoming = readIncomingAttachmentMetadata(cipherData);
|
||||
if (!incoming.length) return;
|
||||
|
||||
const currentById = new Map((await storage.getAttachmentsByCipher(cipherId)).map((attachment) => [attachment.id, attachment]));
|
||||
for (const item of incoming) {
|
||||
const attachment = currentById.get(item.id);
|
||||
if (!attachment) continue;
|
||||
|
||||
let changed = false;
|
||||
if (item.hasFileName) {
|
||||
const fileName = String(item.fileName || '').trim();
|
||||
if (isValidEncString(fileName) && fileName !== attachment.fileName) {
|
||||
attachment.fileName = fileName;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.hasKey) {
|
||||
const key = optionalEncString(item.key);
|
||||
if (key !== attachment.key) {
|
||||
attachment.key = key;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.hasFileSize) {
|
||||
const size = Number(item.fileSize);
|
||||
if (Number.isFinite(size) && size >= 0 && size !== Number(attachment.size || 0)) {
|
||||
attachment.size = size;
|
||||
attachment.sizeName = formatAttachmentSize(size);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await storage.saveAttachment(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCipherEmbeddedAttachmentMetadata(cipherData: any, attachments: Attachment[]): Attachment[] {
|
||||
const incoming = readIncomingAttachmentMetadata(cipherData);
|
||||
if (!incoming.length || !attachments.length) return attachments;
|
||||
|
||||
const incomingById = new Map(incoming.map((item) => [item.id, item]));
|
||||
return attachments.map((attachment) => {
|
||||
const item = incomingById.get(attachment.id);
|
||||
if (!item) return attachment;
|
||||
|
||||
const next: Attachment = { ...attachment };
|
||||
if (item.hasFileName) {
|
||||
const fileName = String(item.fileName || '').trim();
|
||||
if (isValidEncString(fileName)) {
|
||||
next.fileName = fileName;
|
||||
}
|
||||
}
|
||||
if (item.hasKey) {
|
||||
next.key = optionalEncString(item.key);
|
||||
}
|
||||
if (item.hasFileSize) {
|
||||
const size = Number(item.fileSize);
|
||||
if (Number.isFinite(size) && size >= 0) {
|
||||
next.size = size;
|
||||
next.sizeName = formatAttachmentSize(size);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
|
||||
if (!Array.isArray(fields) || fields.length === 0) return null;
|
||||
const out = fields
|
||||
@@ -329,6 +519,7 @@ export function cipherToResponse(
|
||||
'licenseNumber',
|
||||
]);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||
|
||||
return {
|
||||
// Pass through ALL stored cipher fields (known + unknown)
|
||||
@@ -350,7 +541,7 @@ export function cipherToResponse(
|
||||
},
|
||||
object: 'cipherDetails',
|
||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
||||
attachments: formatAttachments(attachments),
|
||||
attachments: formatAttachments(responseAttachments),
|
||||
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
|
||||
notes: optionalEncString(cipher.notes),
|
||||
login: normalizedLogin,
|
||||
@@ -524,8 +715,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
||||
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
|
||||
|
||||
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
|
||||
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
|
||||
}
|
||||
|
||||
@@ -580,6 +772,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||
}
|
||||
|
||||
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||
await storage.saveCipher(cipher);
|
||||
const revisionDate = await storage.updateRevisionDate(userId);
|
||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||
|
||||
@@ -77,6 +77,82 @@ function handleMissingWebsiteIcon(): Response {
|
||||
});
|
||||
}
|
||||
|
||||
function isPrivateIpv4(hostname: string): boolean {
|
||||
const parts = hostname.split('.').map((part) => Number(part));
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
||||
const [a, b] = parts;
|
||||
return (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
a === 0
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedChangePasswordHost(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase().replace(/\.+$/, '');
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized.endsWith('.localhost') ||
|
||||
normalized.endsWith('.local') ||
|
||||
normalized === '::1' ||
|
||||
normalized.startsWith('[') ||
|
||||
isPrivateIpv4(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function parsePublicHttpUrl(rawUri: string | null): URL | null {
|
||||
if (!rawUri) return null;
|
||||
try {
|
||||
const url = new URL(rawUri);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (isBlockedChangePasswordHost(url.hostname)) return null;
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePasswordUri(request: Request): Promise<Response> {
|
||||
const sourceUrl = parsePublicHttpUrl(new URL(request.url).searchParams.get('uri'));
|
||||
if (!sourceUrl) {
|
||||
return jsonResponse({ uri: null });
|
||||
}
|
||||
|
||||
const wellKnownUrl = new URL('/.well-known/change-password', sourceUrl.origin);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), ICON_UPSTREAM_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(wellKnownUrl.toString(), {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: controller.signal,
|
||||
cf: {
|
||||
cacheEverything: true,
|
||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||
},
|
||||
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||
|
||||
if (response.status < 300 || response.status >= 400) {
|
||||
return jsonResponse({ uri: null });
|
||||
}
|
||||
|
||||
const location = response.headers.get('Location');
|
||||
if (!location) return jsonResponse({ uri: null });
|
||||
|
||||
const targetUrl = parsePublicHttpUrl(new URL(location, wellKnownUrl).toString());
|
||||
if (!targetUrl) return jsonResponse({ uri: null });
|
||||
|
||||
return jsonResponse({ uri: targetUrl.toString() });
|
||||
} catch {
|
||||
return jsonResponse({ uri: null });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildIconServiceBase(origin: string): string {
|
||||
return `${origin}/icons`;
|
||||
}
|
||||
@@ -284,6 +360,12 @@ export async function handlePublicRoute(
|
||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
||||
}
|
||||
|
||||
if (path === '/icons/change-password-uri' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleChangePasswordUri(request);
|
||||
}
|
||||
|
||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||
if (iconMatch && method === 'GET') {
|
||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||
|
||||
@@ -39,6 +39,10 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
|
||||
'favorite',
|
||||
'reprompt',
|
||||
'key',
|
||||
'attachments',
|
||||
'Attachments',
|
||||
'attachments2',
|
||||
'Attachments2',
|
||||
'createdAt',
|
||||
'created_at',
|
||||
'creationDate',
|
||||
|
||||
@@ -38,11 +38,10 @@ function isWildcardCorsPath(path: string): boolean {
|
||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||
const url = new URL(request.url);
|
||||
const origin = request.headers.get('Origin');
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
if (!origin) {
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
return isWildcardCorsPath(url.pathname)
|
||||
? { allowOrigin: '*', allowCredentials: false }
|
||||
: { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
if (origin === url.origin) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
@@ -50,6 +49,9 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
||||
if (isExtensionOrigin(origin)) {
|
||||
return { allowOrigin: origin, allowCredentials: true };
|
||||
}
|
||||
if (isWildcardCorsPath(url.pathname)) {
|
||||
return { allowOrigin: '*', allowCredentials: false };
|
||||
}
|
||||
return { allowOrigin: null, allowCredentials: false };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user