From 9195ceb70dae504a5e04c2deee2b4dc9f1c2a2cb Mon Sep 17 00:00:00 2001 From: sai80082 Date: Fri, 24 Apr 2026 15:54:40 +0530 Subject: [PATCH] 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());