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,
|
verifyAttachmentUploadToken,
|
||||||
verifyFileDownloadToken,
|
verifyFileDownloadToken,
|
||||||
} from '../utils/jwt';
|
} from '../utils/jwt';
|
||||||
import { cipherToResponse } from './ciphers';
|
import { applyCipherEmbeddedAttachmentMetadata, cipherToResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import {
|
import {
|
||||||
@@ -282,6 +282,7 @@ export async function handleGetAttachment(
|
|||||||
if (!attachment || attachment.cipherId !== cipherId) {
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
const responseAttachment = applyCipherEmbeddedAttachmentMetadata(cipher, [attachment])[0] || attachment;
|
||||||
|
|
||||||
// Generate short-lived download token
|
// Generate short-lived download token
|
||||||
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
const token = await createFileDownloadToken(cipherId, attachmentId, env.JWT_SECRET);
|
||||||
@@ -292,12 +293,12 @@ export async function handleGetAttachment(
|
|||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'attachment',
|
object: 'attachment',
|
||||||
id: attachment.id,
|
id: responseAttachment.id,
|
||||||
url: downloadUrl,
|
url: downloadUrl,
|
||||||
fileName: attachment.fileName,
|
fileName: responseAttachment.fileName,
|
||||||
key: attachment.key,
|
key: responseAttachment.key,
|
||||||
size: String(Number(attachment.size) || 0),
|
size: String(Number(responseAttachment.size) || 0),
|
||||||
sizeName: attachment.sizeName,
|
sizeName: responseAttachment.sizeName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+195
-2
@@ -263,6 +263,196 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
return formatted.length ? formatted : 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 {
|
function normalizeCipherFieldsForCompatibility(fields: any): any[] | null {
|
||||||
if (!Array.isArray(fields) || fields.length === 0) return null;
|
if (!Array.isArray(fields) || fields.length === 0) return null;
|
||||||
const out = fields
|
const out = fields
|
||||||
@@ -329,6 +519,7 @@ export function cipherToResponse(
|
|||||||
'licenseNumber',
|
'licenseNumber',
|
||||||
]);
|
]);
|
||||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
@@ -350,7 +541,7 @@ export function cipherToResponse(
|
|||||||
},
|
},
|
||||||
object: 'cipherDetails',
|
object: 'cipherDetails',
|
||||||
collectionIds: Array.isArray((passthrough as any).collectionIds) ? (passthrough as any).collectionIds : [],
|
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,
|
name: isValidEncString(cipher.name) ? cipher.name.trim() : cipher.name,
|
||||||
notes: optionalEncString(cipher.notes),
|
notes: optionalEncString(cipher.notes),
|
||||||
login: normalizedLogin,
|
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 incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
|
||||||
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
|
||||||
const incomingRevisionDate = readCipherRevisionDate(cipherData);
|
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);
|
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);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
const revisionDate = await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
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 {
|
function buildIconServiceBase(origin: string): string {
|
||||||
return `${origin}/icons`;
|
return `${origin}/icons`;
|
||||||
}
|
}
|
||||||
@@ -284,6 +360,12 @@ export async function handlePublicRoute(
|
|||||||
return jsonResponse(await buildWebBootstrapResponse(env));
|
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);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch && method === 'GET') {
|
if (iconMatch && method === 'GET') {
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
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',
|
'favorite',
|
||||||
'reprompt',
|
'reprompt',
|
||||||
'key',
|
'key',
|
||||||
|
'attachments',
|
||||||
|
'Attachments',
|
||||||
|
'attachments2',
|
||||||
|
'Attachments2',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'created_at',
|
'created_at',
|
||||||
'creationDate',
|
'creationDate',
|
||||||
|
|||||||
@@ -38,11 +38,10 @@ function isWildcardCorsPath(path: string): boolean {
|
|||||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
if (isWildcardCorsPath(url.pathname)) {
|
|
||||||
return { allowOrigin: '*', allowCredentials: false };
|
|
||||||
}
|
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return { allowOrigin: null, allowCredentials: false };
|
return isWildcardCorsPath(url.pathname)
|
||||||
|
? { allowOrigin: '*', allowCredentials: false }
|
||||||
|
: { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
if (origin === url.origin) {
|
if (origin === url.origin) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
@@ -50,6 +49,9 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
|||||||
if (isExtensionOrigin(origin)) {
|
if (isExtensionOrigin(origin)) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
|
return { allowOrigin: '*', allowCredentials: false };
|
||||||
|
}
|
||||||
return { allowOrigin: null, allowCredentials: false };
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user