From 9195ceb70dae504a5e04c2deee2b4dc9f1c2a2cb Mon Sep 17 00:00:00 2001 From: sai80082 Date: Fri, 24 Apr 2026 15:54:40 +0530 Subject: [PATCH 1/2] feat: add support for Ente platform integration - Updated importJobs schema to include 'ente' as a valid platform. - Enhanced import-shared API to handle Ente shared links, including detection and fetching of albums. - Introduced new ente.ts module for Ente-specific decryption and API interactions. - Implemented thumbnail and video handling for Ente assets in import-shared API. - Created EnteSharedLinkProcessor for processing Ente shared links during import jobs. - Updated asset interfaces to accommodate Ente-specific encryption artifacts. - Registered EnteSharedLinkProcessor in the import processors registry. - Added necessary dependencies (bs58, libsodium-wrappers-sumo) to package.json and bun.lock. --- bun.lock | 13 + package.json | 3 + src/db/schema/importJobs.schema.ts | 2 +- src/pages/api/import-shared.ts | 92 ++++- src/pages/api/import-shared/ente.ts | 328 ++++++++++++++++++ src/pages/api/import-shared/thumbnail.ts | 28 ++ src/pages/api/import-shared/video.ts | 5 + src/pages/assets/import-shared.tsx | 39 ++- .../import/processors/ente-shared-link.ts | 240 +++++++++++++ src/workers/import/processors/index.ts | 2 + 10 files changed, 743 insertions(+), 9 deletions(-) create mode 100644 src/pages/api/import-shared/ente.ts create mode 100644 src/workers/import/processors/ente-shared-link.ts diff --git a/bun.lock b/bun.lock index d7091b5..975810e 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "@xyflow/react": "^12.10.2", "ai": "^6.0.146", "axios": "^1.14.0", + "bs58": "^6.0.0", "chrono-node": "^1.4.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -52,6 +53,7 @@ "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", + "libsodium-wrappers-sumo": "^0.8.2", "lucide-react": "^0.428.0", "next": "15.5.14", "next-themes": "^0.3.0", @@ -80,6 +82,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/leaflet": "^1.9.21", "@types/leaflet.heat": "^0.2.5", + "@types/libsodium-wrappers-sumo": "^0.8.2", "@types/node": "^20.19.39", "@types/node-cron": "3.0.11", "@types/pg": "^8.20.0", @@ -564,6 +567,8 @@ "@types/leaflet.heat": ["@types/leaflet.heat@0.2.5", "", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-wmDePJPn2vhW9BScsWTV4pSfWayhdDwXuWIQzUuaJkvk5DCHsYsIegTD7tybo38E/0mxpuWODKV/LhsxPESWJg=="], + "@types/libsodium-wrappers-sumo": ["@types/libsodium-wrappers-sumo@0.8.2", "", { "dependencies": { "libsodium-wrappers-sumo": "*" } }, "sha512-uFOBpg/r21hExVlh2ty8YpDfSR+Yy3Jn8XS4+SSjitbhTxdYq+pBz/49XRxyUFe8SzqujHf/Wu0/O4d+FUtNfQ=="], + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], @@ -750,6 +755,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ=="], "big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="], @@ -762,6 +769,8 @@ "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "bs58": ["bs58@6.0.0", "", { "dependencies": { "base-x": "^5.0.0" } }, "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], @@ -1218,6 +1227,10 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "libsodium-sumo": ["libsodium-sumo@0.8.2", "", {}, "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q=="], + + "libsodium-wrappers-sumo": ["libsodium-wrappers-sumo@0.8.2", "", { "dependencies": { "libsodium-sumo": "^0.8.0" } }, "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ=="], + "libsql": ["libsql@0.5.29", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.29", "@libsql/darwin-x64": "0.5.29", "@libsql/linux-arm-gnueabihf": "0.5.29", "@libsql/linux-arm-musleabihf": "0.5.29", "@libsql/linux-arm64-gnu": "0.5.29", "@libsql/linux-arm64-musl": "0.5.29", "@libsql/linux-x64-gnu": "0.5.29", "@libsql/linux-x64-musl": "0.5.29", "@libsql/win32-x64-msvc": "0.5.29" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg=="], "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], diff --git a/package.json b/package.json index 8c39c04..7fbb851 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@xyflow/react": "^12.10.2", "ai": "^6.0.146", "axios": "^1.14.0", + "bs58": "^6.0.0", "chrono-node": "^1.4.9", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -64,6 +65,7 @@ "jsonwebtoken": "^9.0.3", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", + "libsodium-wrappers-sumo": "^0.8.2", "lucide-react": "^0.428.0", "next": "15.5.14", "next-themes": "^0.3.0", @@ -92,6 +94,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/leaflet": "^1.9.21", "@types/leaflet.heat": "^0.2.5", + "@types/libsodium-wrappers-sumo": "^0.8.2", "@types/node": "^20.19.39", "@types/node-cron": "3.0.11", "@types/pg": "^8.20.0", diff --git a/src/db/schema/importJobs.schema.ts b/src/db/schema/importJobs.schema.ts index 259f16d..9630ec1 100644 --- a/src/db/schema/importJobs.schema.ts +++ b/src/db/schema/importJobs.schema.ts @@ -4,7 +4,7 @@ import { randomUUID } from "crypto"; export const importJobs = sqliteTable("import_jobs", { id: text("id").primaryKey().$defaultFn(() => randomUUID()), userId: text("user_id").notNull(), - platform: text("platform").notNull(), // 'immich' | 'nextcloud' | 'photoprism' + platform: text("platform").notNull(), // 'immich' | 'nextcloud' | 'ente' | 'photoprism' status: text("status").notNull().default("pending"), // pending | processing | completed | failed url: text("url").notNull(), // base URL of source urlConfig: text("url_config").notNull().default("{}"), // JSON: auth/connection (key, token, etc.) diff --git a/src/pages/api/import-shared.ts b/src/pages/api/import-shared.ts index c2238a3..497f966 100644 --- a/src/pages/api/import-shared.ts +++ b/src/pages/api/import-shared.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { detectNextcloud, checkPasswordProtection, listNextcloudFiles } from "./import-shared/nextcloud"; +import { detectEnte, fetchEnteAlbum } from "./import-shared/ente"; interface IAlbumContributorCount { userId: string; @@ -66,6 +67,10 @@ interface IImportSharedAsset { location?: string | null; thumbhash?: string | null; fileSizeInByte?: number | null; + // Ente-specific encryption artifacts (optional) + enteFileKey?: string; + enteFileDecryptionHeader?: string; + enteThumbnailDecryptionHeader?: string; } interface IImportSharedAlbum { @@ -84,7 +89,7 @@ interface IImportSharedAlbum { } interface IImportSharedResponse { - platform: "immich" | "nextcloud"; + platform: "immich" | "nextcloud" | "ente"; link: string; origin: string; key: string; @@ -98,6 +103,9 @@ interface IImportSharedResponse { showMetadata?: boolean; }; album: IImportSharedAlbum | null; + // Ente-specific (optional) + enteApiBase?: string; + enteAlbumKey?: string; } const respondWithError = (res: NextApiResponse, status: number, message: string) => { @@ -246,10 +254,90 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } + // ── Ente flow ── + const ente = detectEnte(link); + if (ente) { + try { + const result = await fetchEnteAlbum(link); + + const assets: IImportSharedAsset[] = result.files.map((f) => ({ + id: f.id, + originalFileName: f.title, + type: f.type, + fileCreatedAt: f.creationTime + ? new Date(f.creationTime / 1000).toISOString() + : null, + localDateTime: f.modificationTime + ? new Date(f.modificationTime / 1000).toISOString() + : null, + description: null, + location: null, + thumbhash: null, + fileSizeInByte: f.fileSize ?? null, + enteFileKey: f.fileKey, + enteFileDecryptionHeader: f.fileDecryptionHeader, + enteThumbnailDecryptionHeader: f.thumbnailDecryptionHeader, + })); + + const dates = result.files + .map((f) => f.creationTime) + .filter((t) => t > 0); + const startDate = + dates.length > 0 + ? new Date(Math.min(...dates) / 1000).toISOString() + : null; + const endDate = + dates.length > 0 + ? new Date(Math.max(...dates) / 1000).toISOString() + : null; + + const responseBody: IImportSharedResponse = { + platform: "ente", + link: link.trim(), + origin: result.apiBase, + key: result.token, + sharedLink: { + id: result.token, + type: "ALBUM", + createdAt: new Date().toISOString(), + expiresAt: null, + allowUpload: false, + allowDownload: true, + showMetadata: true, + }, + album: { + albumName: result.albumName, + assetCount: assets.length, + owner: null, + description: null, + startDate, + endDate, + shared: true, + hasSharedLink: true, + lastModifiedAssetTimestamp: endDate, + order: null, + contributorCounts: [], + assets, + }, + enteApiBase: result.apiBase, + enteAlbumKey: result.albumKeyB58, + }; + + return res.status(200).json(responseBody); + } catch (error: any) { + console.error("Ente import shared error", error); + return respondWithError( + res, + 500, + error?.message ?? "Failed to fetch Ente album" + ); + } + } + // ── Immich flow ── const parsed = parseSharedLink(link); if (!parsed) { - return respondWithError(res, 400, "Invalid share link. Supported: Immich or Nextcloud share URLs."); + return respondWithError(res, 400, "Invalid share link. Supported: Immich, Nextcloud, or Ente share URLs."); } const authQuery = password diff --git a/src/pages/api/import-shared/ente.ts b/src/pages/api/import-shared/ente.ts new file mode 100644 index 0000000..37ccf24 --- /dev/null +++ b/src/pages/api/import-shared/ente.ts @@ -0,0 +1,328 @@ +import _sodium from "libsodium-wrappers-sumo"; +import bs58 from "bs58"; +import { inferAssetTypeFromName } from "./helpers"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface EnteDetection { + apiBase: string; + token: string; + albumKeyB58: string; + albumKey: Uint8Array; +} + +export interface EnteDecryptedFile { + id: string; + title: string; + type: "IMAGE" | "VIDEO"; + creationTime: number; + modificationTime: number; + fileSize?: number; + fileKey: string; // base64 + fileDecryptionHeader: string; // base64 + thumbnailDecryptionHeader: string; // base64 +} + +export interface EnteAlbumResult { + albumName: string; + apiBase: string; + token: string; + albumKeyB58: string; + files: EnteDecryptedFile[]; +} + +interface EnteCollectionResponse { + collection: { + id: number; + encryptedKey: string; + keyDecryptionNonce: string; + encryptedName: string; + nameDecryptionNonce: string; + publicURLs?: Array<{ + enableDownload?: boolean; + enableCollect?: boolean; + }>; + }; +} + +interface EnteDiffFile { + id: number; + encryptedKey: string; + keyDecryptionNonce: string; + file: { decryptionHeader: string; size: number }; + thumbnail: { decryptionHeader: string; size: number }; + metadata: { + encryptedData: string; + decryptionHeader: string; + size: number; + }; + isDeleted: boolean; + updationTime: number; + info?: { fileSize?: number; thumbSize?: number }; +} + +interface EnteDiffResponse { + diff: EnteDiffFile[]; + hasMore: boolean; +} + +// ── Crypto ─────────────────────────────────────────────────────────────────── + +let sodiumReady: typeof _sodium | null = null; + +const initSodium = async (): Promise => { + if (sodiumReady) return sodiumReady; + await _sodium.ready; + sodiumReady = _sodium; + return _sodium; +}; + +export const decryptSecretBox = async ( + ciphertext_b64: string, + nonce_b64: string, + key: Uint8Array +): Promise => { + const sodium = await initSodium(); + const ciphertext = sodium.from_base64(ciphertext_b64, sodium.base64_variants.ORIGINAL); + const nonce = sodium.from_base64(nonce_b64, sodium.base64_variants.ORIGINAL); + return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key); +}; + +const PLAIN_CHUNK = 4 * 1024 * 1024; // 4 MiB + +export const decryptSecretStream = async ( + encryptedData: Buffer | Uint8Array, + header_b64: string, + key: Uint8Array +): Promise => { + const sodium = await initSodium(); + const header = sodium.from_base64(header_b64, sodium.base64_variants.ORIGINAL); + const ABYTES = sodium.crypto_secretstream_xchacha20poly1305_ABYTES; + const ENC_CHUNK = PLAIN_CHUNK + ABYTES; + + const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key); + const out: Uint8Array[] = []; + let offset = 0; + const total = encryptedData.length; + + while (offset < total) { + const end = Math.min(offset + ENC_CHUNK, total); + const chunk = encryptedData.subarray(offset, end); + const result = sodium.crypto_secretstream_xchacha20poly1305_pull(state, chunk, null); + if (!result) throw new Error("Decryption failed: invalid secretstream chunk"); + out.push(result.message); + offset = end; + } + + return Buffer.concat(out); +}; + +// ── URL Detection ──────────────────────────────────────────────────────────── + +/** + * Detect if a URL is an Ente public album link. + * Format: https://albums.ente.io/?t=TOKEN#BASE58KEY + * Or self-hosted: https://albums.example.com/?t=TOKEN#BASE58KEY + */ +export const detectEnte = (url: string): EnteDetection | null => { + const trimmed = url.trim(); + + // Extract fragment from raw string (new URL strips it) + const hashIdx = trimmed.indexOf("#"); + if (hashIdx < 0) return null; + const fragment = trimmed.substring(hashIdx + 1); + if (!fragment) return null; + + // Parse the part before the fragment + let parsed: URL; + try { + parsed = new URL(trimmed.substring(0, hashIdx)); + } catch { + return null; + } + + const token = parsed.searchParams.get("t"); + if (!token) return null; + + // Validate base58 key + let albumKey: Uint8Array; + try { + albumKey = bs58.decode(fragment); + if (albumKey.length !== 32) return null; // XSalsa20-Poly1305 key must be 32 bytes + } catch { + return null; + } + + const apiBase = resolveApiBase(parsed); + + return { apiBase, token, albumKeyB58: fragment, albumKey }; +}; + +const resolveApiBase = (parsed: URL): string => { + if (parsed.hostname === "albums.ente.io") { + return "https://api.ente.io"; + } + // Self-hosted: replace "albums." prefix with "api." if present + if (parsed.hostname.startsWith("albums.")) { + const rest = parsed.hostname.substring("albums.".length); + return `${parsed.protocol}//api.${rest}`; + } + // Fallback: same origin + return parsed.origin; +}; + +// ── Ente API Client ────────────────────────────────────────────────────────── + +const enteHeaders = (token: string): Record => ({ + "X-Auth-Access-Token": token, + Accept: "application/json", +}); + +const getCollectionInfo = async ( + apiBase: string, + token: string +): Promise => { + const resp = await fetch(`${apiBase}/public-collection/info`, { + headers: enteHeaders(token), + timeout: 10_000, + } as RequestInit); + if (!resp.ok) { + throw new Error(`Ente API /public-collection/info failed (status ${resp.status})`); + } + return resp.json(); +}; + +const getCollectionFiles = async ( + apiBase: string, + token: string +): Promise => { + const files: EnteDiffFile[] = []; + let sinceTime = 0; + + while (true) { + const resp = await fetch( + `${apiBase}/public-collection/diff?sinceTime=${sinceTime}`, + { headers: enteHeaders(token), timeout: 15_000 } as RequestInit + ); + if (!resp.ok) { + throw new Error(`Ente API /public-collection/diff failed (status ${resp.status})`); + } + const data: EnteDiffResponse = await resp.json(); + const batch = data.diff.filter((f) => !f.isDeleted); + files.push(...batch); + + if (!data.hasMore) break; + sinceTime = Math.max(...data.diff.map((f) => f.updationTime)); + } + + return files; +}; + +/** + * Get the presigned download URL for an Ente file or thumbnail. + * The API returns a 307 redirect — we capture the Location header. + * Note: Ente uses "preview" (not "thumbnail") for the thumbnail endpoint. + */ +export const getFileRedirectUrl = async ( + apiBase: string, + token: string, + fileId: string | number, + kind: "download" | "preview" +): Promise => { + const resp = await fetch( + `${apiBase}/public-collection/files/${kind}/${fileId}`, + { + headers: enteHeaders(token), + redirect: "manual", + timeout: 15_000, + } as RequestInit + ); + + if (resp.status >= 301 && resp.status <= 308) { + const location = resp.headers.get("Location"); + if (location) return location; + } + + if (resp.ok) return resp.url; + + throw new Error( + `Ente API files/${kind}/${fileId} failed (status ${resp.status})` + ); +}; + +// ── High-level Orchestrator ────────────────────────────────────────────────── + +/** + * Fetch and decrypt an Ente shared album. + * Returns decrypted album name, file metadata, and encryption artifacts for later download. + */ +export const fetchEnteAlbum = async (url: string): Promise => { + const detection = detectEnte(url); + if (!detection) throw new Error("Invalid Ente album URL"); + + const { apiBase, token, albumKey, albumKeyB58 } = detection; + const sodium = await initSodium(); + + // Fetch collection info & decrypt album name + const info = await getCollectionInfo(apiBase, token); + const collection = info.collection; + + let albumName = "Ente Album"; + try { + const nameBytes = await decryptSecretBox( + collection.encryptedName, + collection.nameDecryptionNonce, + albumKey + ); + albumName = sodium.to_string(nameBytes); + } catch { + // Album name decryption failed — use default + } + + // Fetch all files + const rawFiles = await getCollectionFiles(apiBase, token); + + // Decrypt metadata for each file + const files: EnteDecryptedFile[] = []; + + for (const file of rawFiles) { + try { + // Decrypt per-file key using album key + const fileKey = await decryptSecretBox( + file.encryptedKey, + file.keyDecryptionNonce, + albumKey + ); + + // Decrypt metadata using file key + secretstream + const metadataBuffer = await decryptSecretStream( + Buffer.from(file.metadata.encryptedData, "base64"), + file.metadata.decryptionHeader, + fileKey + ); + const metadata = JSON.parse(metadataBuffer.toString("utf-8")); + + const title: string = metadata.title || `file_${file.id}`; + const fileType: number = metadata.fileType ?? 0; // 0=image, 1=video + const type: "IMAGE" | "VIDEO" = + fileType === 1 ? "VIDEO" : inferAssetTypeFromName(title) === "VIDEO" ? "VIDEO" : "IMAGE"; + + files.push({ + id: String(file.id), + title, + type, + creationTime: metadata.creationTime ?? 0, + modificationTime: metadata.modificationTime ?? metadata.creationTime ?? 0, + fileSize: file.info?.fileSize, + fileKey: sodium.to_base64(fileKey, sodium.base64_variants.ORIGINAL), + fileDecryptionHeader: file.file.decryptionHeader, + thumbnailDecryptionHeader: file.thumbnail.decryptionHeader, + }); + } catch (err) { + console.warn(`[Ente] Failed to decrypt metadata for file ${file.id}:`, err); + // Skip files we can't decrypt + } + } + + return { albumName, apiBase, token, albumKeyB58, files }; +}; diff --git a/src/pages/api/import-shared/thumbnail.ts b/src/pages/api/import-shared/thumbnail.ts index ae120c6..349fc7d 100644 --- a/src/pages/api/import-shared/thumbnail.ts +++ b/src/pages/api/import-shared/thumbnail.ts @@ -1,4 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import { getFileRedirectUrl, decryptSecretStream, decryptSecretBox } from "./ente"; +import _sodium from "libsodium-wrappers-sumo"; const respondWithError = (res: NextApiResponse, status: number, message: string) => { return res.status(status).json({ error: message }); @@ -59,6 +61,32 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } try { + // ── Ente: encrypted thumbnails ── + if (params.platform === "ente") { + const enteFileKey = typeof req.query.enteFileKey === "string" ? req.query.enteFileKey : ""; + const enteThumbnailHeader = typeof req.query.enteThumbnailHeader === "string" ? req.query.enteThumbnailHeader : ""; + const apiBase = typeof req.query.apiBase === "string" ? req.query.apiBase : params.origin; + + if (!enteFileKey || !enteThumbnailHeader) { + return respondWithError(res, 400, "Missing Ente encryption params (enteFileKey, enteThumbnailHeader)"); + } + + await _sodium.ready; + const fileKeyBytes = _sodium.from_base64(enteFileKey, _sodium.base64_variants.ORIGINAL); + + const redirectUrl = await getFileRedirectUrl(apiBase, params.key, params.assetId, "preview"); + const encryptedResp = await fetch(redirectUrl); + if (!encryptedResp.ok) { + return respondWithError(res, encryptedResp.status, "Failed to fetch Ente thumbnail"); + } + const encrypted = Buffer.from(await encryptedResp.arrayBuffer()); + const decrypted = await decryptSecretStream(encrypted, enteThumbnailHeader, fileKeyBytes); + + res.setHeader("Content-Type", "image/jpeg"); + res.setHeader("Cache-Control", "public, max-age=300"); + return res.status(200).send(decrypted); + } + let targetUrl: string; if (params.platform === "nextcloud") { diff --git a/src/pages/api/import-shared/video.ts b/src/pages/api/import-shared/video.ts index 876db05..c1a64be 100644 --- a/src/pages/api/import-shared/video.ts +++ b/src/pages/api/import-shared/video.ts @@ -56,6 +56,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } try { + // Ente videos are encrypted — full decryption for preview is too expensive + if (params.platform === "ente") { + return respondWithError(res, 501, "Video preview is not available for Ente imports"); + } + let targetUrl: string; let fetchHeaders: Record; diff --git a/src/pages/assets/import-shared.tsx b/src/pages/assets/import-shared.tsx index 97e7ec0..1292a6f 100644 --- a/src/pages/assets/import-shared.tsx +++ b/src/pages/assets/import-shared.tsx @@ -66,6 +66,10 @@ interface IImportSharedAsset { duration?: string | null; isFavorite?: boolean; isArchived?: boolean; + // Ente-specific encryption artifacts (optional) + enteFileKey?: string; + enteFileDecryptionHeader?: string; + enteThumbnailDecryptionHeader?: string; } interface IImportSharedAlbum { @@ -87,7 +91,7 @@ interface IImportSharedAlbum { } interface IImportSharedResponse { - platform: "immich" | "nextcloud"; + platform: "immich" | "nextcloud" | "ente"; link: string; origin: string; key: string; @@ -101,6 +105,9 @@ interface IImportSharedResponse { showMetadata?: boolean; }; album: IImportSharedAlbum | null; + // Ente-specific (optional) + enteApiBase?: string; + enteAlbumKey?: string; } const formatDate = (value?: string | null) => { @@ -340,6 +347,11 @@ export default function ImportSharedPage() { platform: sharedData.platform, }); if (asset.thumbhash) params.set("thumbhash", asset.thumbhash); + if (sharedData.platform === "ente") { + if (asset.enteFileKey) params.set("enteFileKey", asset.enteFileKey); + if (asset.enteThumbnailDecryptionHeader) params.set("enteThumbnailHeader", asset.enteThumbnailDecryptionHeader); + if (sharedData.enteApiBase) params.set("apiBase", sharedData.enteApiBase); + } return `/api/import-shared/thumbnail?${params.toString()}`; }; @@ -368,6 +380,11 @@ export default function ImportSharedPage() { platform: sharedData.platform, }); if (asset.thumbhash) params.set("thumbhash", asset.thumbhash); + if (sharedData.platform === "ente") { + if (asset.enteFileKey) params.set("enteFileKey", asset.enteFileKey); + if (asset.enteThumbnailDecryptionHeader) params.set("enteThumbnailHeader", asset.enteThumbnailDecryptionHeader); + if (sharedData.enteApiBase) params.set("apiBase", sharedData.enteApiBase); + } return `/api/import-shared/thumbnail?${params.toString()}`; }; @@ -400,10 +417,15 @@ export default function ImportSharedPage() { setImportAllLoading(true); try { const isNextcloud = sharedData.platform === "nextcloud"; + const isEnte = sharedData.platform === "ente"; const urlConfig: Record = { key: sharedData.key }; if (isNextcloud && shareLinkPassword) { urlConfig.password = shareLinkPassword; } + if (isEnte) { + if (sharedData.enteApiBase) urlConfig.apiBase = sharedData.enteApiBase; + if (sharedData.enteAlbumKey) urlConfig.albumKey = sharedData.enteAlbumKey; + } const job = await createImportJob({ platform: sharedData.platform, @@ -420,6 +442,11 @@ export default function ImportSharedPage() { isFavorite: asset.isFavorite ?? false, isArchived: asset.isArchived ?? false, ...(isNextcloud ? { relativePath: asset.id } : {}), + ...(isEnte ? { + enteFileKey: asset.enteFileKey ?? null, + enteFileDecryptionHeader: asset.enteFileDecryptionHeader ?? null, + enteThumbnailDecryptionHeader: asset.enteThumbnailDecryptionHeader ?? null, + } : {}), })), }); setActiveJobId(job.jobId); @@ -494,7 +521,7 @@ export default function ImportSharedPage() {

Import a shared album

- Paste a public shared link from Immich or Nextcloud to import its assets into your Immich instance. + Paste a public shared link from Immich, Nextcloud, or Ente to import its assets into your Immich instance.

@@ -511,7 +538,7 @@ export default function ImportSharedPage() { setShareLinkPassword(""); } }} - placeholder="Immich or Nextcloud share URL" + placeholder="Immich, Nextcloud, or Ente share URL" type="url" required className="pl-9" @@ -710,7 +737,7 @@ export default function ImportSharedPage() { : "hover:opacity-90" }`} onClick={() => { - if (isVideoAsset) { + if (isVideoAsset && sharedData?.platform !== "ente") { setActiveImageAsset(null); setActiveVideoAsset(asset); setPreviewLoading(false); @@ -751,8 +778,8 @@ export default function ImportSharedPage() { /> - {/* Video play indicator */} - {isVideoAsset && ( + {/* Video play indicator (hidden for Ente — no video preview) */} + {isVideoAsset && sharedData?.platform !== "ente" && (
diff --git a/src/workers/import/processors/ente-shared-link.ts b/src/workers/import/processors/ente-shared-link.ts new file mode 100644 index 0000000..5f1878d --- /dev/null +++ b/src/workers/import/processors/ente-shared-link.ts @@ -0,0 +1,240 @@ +import { eq } from "drizzle-orm"; +import { appDb } from "@/db"; +import { importJobItems } from "@/db/schema"; +import { ENV } from "@/config/environment"; +import { + ensurePowerToolsTag, + HeadersRecord, + createImmichAlbum, + addAssetToAlbum, + uploadAssetBuffer, + SharedAssetPayload, + DownloadedAssetPayload, + DEVICE_ID, + guessContentType, +} from "@/pages/api/import-shared/helpers"; +import { + getFileRedirectUrl, + decryptSecretStream, +} from "@/pages/api/import-shared/ente"; +import _sodium from "libsodium-wrappers-sumo"; +import type { + ImportJob, + ImportJobItem, + ImportProcessor, + ProcessorContext, + SetupResult, +} from "../types"; + +const makeDeviceAssetId = (fileId: string) => `shared-ente-${fileId}`; + +const downloadEnteAsset = async ( + apiBase: string, + token: string, + fileId: string, + fileDecryptionHeader: string, + fileKey: Uint8Array, + title: string +): Promise => { + const redirectUrl = await getFileRedirectUrl(apiBase, token, fileId, "download"); + + const response = await fetch(redirectUrl, { + method: "GET", + headers: { accept: "*/*" }, + }); + + if (!response.ok) { + throw new Error(`Failed to download Ente file ${fileId} (status ${response.status})`); + } + + const encrypted = Buffer.from(await response.arrayBuffer()); + const decrypted = await decryptSecretStream(encrypted, fileDecryptionHeader, fileKey); + + return { + buffer: decrypted, + fileName: title, + contentType: guessContentType(title), + }; +}; + +export class EnteSharedLinkProcessor implements ImportProcessor { + async setup(job: ImportJob, context: ProcessorContext): Promise { + const headers = context.headers as HeadersRecord; + + const items = await appDb + .select() + .from(importJobItems) + .where(eq(importJobItems.jobId, job.id)); + + // Build deviceAssetIds for dedup check + const deviceAssetIds: string[] = []; + const deviceAssetIdLookup = new Map(); + for (const item of items) { + const deviceAssetId = makeDeviceAssetId(item.assetId); + deviceAssetIds.push(deviceAssetId); + deviceAssetIdLookup.set(deviceAssetId.toLowerCase(), item.assetId); + } + + // Check which assets already exist in Immich + const skipAssetIds: string[] = []; + if (deviceAssetIds.length > 0) { + try { + const jsonHeaders: HeadersRecord = { ...headers, "Content-Type": "application/json" }; + const checkResponse = await fetch(`${ENV.IMMICH_URL}/api/assets/exist`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ deviceAssetIds, deviceId: DEVICE_ID }), + }); + + if (checkResponse.ok) { + const existPayload = await checkResponse.json().catch(() => ({})); + const existingIds: string[] = existPayload?.existingIds ?? []; + for (const deviceAssetId of existingIds) { + if (typeof deviceAssetId !== "string") continue; + const matchedAssetId = deviceAssetIdLookup.get(deviceAssetId.toLowerCase()); + if (matchedAssetId) { + skipAssetIds.push(matchedAssetId); + } + } + } else { + console.warn(`[EnteProcessor] Failed to check existing assets (status ${checkResponse.status})`); + } + } catch (error) { + console.warn("[EnteProcessor] Unable to check existing assets", error); + } + } + + // Parse importData for album options + let importData: Record = {}; + try { + importData = JSON.parse(job.importData); + } catch { + // ignore + } + + const albumOptions = importData.albumOptions as { + createAlbum?: boolean; + albumName?: string; + addToAlbumId?: string; + } | undefined; + + let albumId: string | undefined; + const jsonHeaders: HeadersRecord = { ...headers, "Content-Type": "application/json" }; + + if (albumOptions?.createAlbum) { + const desiredAlbumName = + albumOptions.albumName?.trim() || `Ente import ${new Date().toISOString()}`; + albumId = await createImmichAlbum(desiredAlbumName, jsonHeaders); + console.log(`[EnteProcessor] Created album ${albumId} (${desiredAlbumName})`); + } else if (typeof albumOptions?.addToAlbumId === "string" && albumOptions.addToAlbumId.trim()) { + albumId = albumOptions.addToAlbumId.trim(); + } + + const importDataPatch: Record = {}; + if (albumId) importDataPatch.albumId = albumId; + + const tagAssets = importData.tagAssets !== false; + if (tagAssets) { + try { + const tag = await ensurePowerToolsTag(jsonHeaders); + importDataPatch.tagId = tag.id; + } catch (err) { + console.warn("[EnteProcessor] Failed to create power-tools tag, skipping tagging", err); + } + } + + return { skipAssetIds, albumId, importDataPatch }; + } + + async processItem( + job: ImportJob, + item: ImportJobItem, + context: ProcessorContext + ): Promise<{ immichId: string }> { + const headers = context.headers as HeadersRecord; + + // Parse item metadata + encryption artifacts + let title = `file_${item.assetId}`; + let type = "IMAGE"; + let fileCreatedAt: string | null = null; + let localDateTime: string | null = null; + let enteFileKey = ""; + let enteFileDecryptionHeader = ""; + + try { + const parsed = JSON.parse(item.itemData); + title = parsed.originalFileName ?? parsed.title ?? title; + type = parsed.type ?? "IMAGE"; + fileCreatedAt = parsed.fileCreatedAt ?? null; + localDateTime = parsed.localDateTime ?? null; + enteFileKey = parsed.enteFileKey ?? ""; + enteFileDecryptionHeader = parsed.enteFileDecryptionHeader ?? ""; + } catch { + // ignore parse errors + } + + // Parse urlConfig for token and apiBase + let urlConfig: Record = {}; + try { + urlConfig = JSON.parse(job.urlConfig); + } catch { + // ignore + } + const token = typeof urlConfig.key === "string" ? urlConfig.key : ""; + const apiBase = typeof urlConfig.apiBase === "string" ? urlConfig.apiBase : job.url; + + if (!enteFileKey || !enteFileDecryptionHeader) { + throw new Error(`Missing encryption artifacts for Ente file ${item.assetId}`); + } + + // Decode file key from base64 + await _sodium.ready; + const fileKeyBytes = _sodium.from_base64(enteFileKey, _sodium.base64_variants.ORIGINAL); + + // Download and decrypt + const downloaded = await downloadEnteAsset( + apiBase, + token, + item.assetId, + enteFileDecryptionHeader, + fileKeyBytes, + title + ); + + // Build asset payload for upload + const asset: SharedAssetPayload = { + id: item.assetId, + originalFileName: title, + type, + fileCreatedAt, + localDateTime, + duration: null, + isFavorite: false, + isArchived: false, + }; + + // Strip Content-Type for multipart upload + const { ["Content-Type"]: _omit, ...uploadHeaders } = headers; + + // Read tagId from importData set during setup() + const importData = JSON.parse(job.importData) as { albumId?: string; tagId?: string }; + const tagId = importData.tagId; + + const immichId = await uploadAssetBuffer( + asset, + downloaded, + uploadHeaders as HeadersRecord, + headers as HeadersRecord, + makeDeviceAssetId(item.assetId), + tagId + ); + + // Add to album if one was resolved during setup + if (context.albumId) { + const jsonHeaders: HeadersRecord = { ...headers, "Content-Type": "application/json" }; + await addAssetToAlbum(context.albumId, immichId, jsonHeaders); + } + + return { immichId }; + } +} diff --git a/src/workers/import/processors/index.ts b/src/workers/import/processors/index.ts index 19c1d3d..b614482 100644 --- a/src/workers/import/processors/index.ts +++ b/src/workers/import/processors/index.ts @@ -1,6 +1,8 @@ import { registerProcessor } from "../registry"; import { ImmichSharedLinkProcessor } from "./immich-shared-link"; import { NextcloudSharedLinkProcessor } from "./nextcloud-shared-link"; +import { EnteSharedLinkProcessor } from "./ente-shared-link"; registerProcessor("immich", new ImmichSharedLinkProcessor()); registerProcessor("nextcloud", new NextcloudSharedLinkProcessor()); +registerProcessor("ente", new EnteSharedLinkProcessor()); From 9659c8b36bfd8cade7cb371a3bf3b75a1fb31e49 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Fri, 10 Jul 2026 14:00:21 +0000 Subject: [PATCH 2/2] fix: remove stray argument breaking uploadAssetBuffer call makeDeviceAssetId(item.assetId) was passed as an extra 5th argument before tagId, which doesn't match uploadAssetBuffer's signature (asset, payload, uploadHeaders, jsonHeaders, tagId?) and broke the TS build. Match the call pattern used by the sibling nextcloud/immich processors. --- src/workers/import/processors/ente-shared-link.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/workers/import/processors/ente-shared-link.ts b/src/workers/import/processors/ente-shared-link.ts index 5f1878d..f630e8c 100644 --- a/src/workers/import/processors/ente-shared-link.ts +++ b/src/workers/import/processors/ente-shared-link.ts @@ -225,7 +225,6 @@ export class EnteSharedLinkProcessor implements ImportProcessor { downloaded, uploadHeaders as HeadersRecord, headers as HeadersRecord, - makeDeviceAssetId(item.assetId), tagId );