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.
This commit is contained in:
sai80082
2026-04-24 15:54:40 +05:30
parent c32e38bac7
commit 9195ceb70d
10 changed files with 743 additions and 9 deletions
+13
View File
@@ -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=="],
+3
View File
@@ -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",
+1 -1
View File
@@ -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.)
+90 -2
View File
@@ -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
+328
View File
@@ -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<typeof _sodium> => {
if (sodiumReady) return sodiumReady;
await _sodium.ready;
sodiumReady = _sodium;
return _sodium;
};
export const decryptSecretBox = async (
ciphertext_b64: string,
nonce_b64: string,
key: Uint8Array
): Promise<Uint8Array> => {
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<Buffer> => {
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<string, string> => ({
"X-Auth-Access-Token": token,
Accept: "application/json",
});
const getCollectionInfo = async (
apiBase: string,
token: string
): Promise<EnteCollectionResponse> => {
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<EnteDiffFile[]> => {
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<string> => {
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<EnteAlbumResult> => {
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 };
};
+28
View File
@@ -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") {
+5
View File
@@ -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<string, string>;
+33 -6
View File
@@ -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<string, string> = { 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() {
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">Import a shared album</h1>
<p className="text-sm text-muted-foreground">
Paste a public shared link from <strong className="font-medium">Immich</strong> or <strong className="font-medium">Nextcloud</strong> to import its assets into your Immich instance.
Paste a public shared link from <strong className="font-medium">Immich</strong>, <strong className="font-medium">Nextcloud</strong>, or <strong className="font-medium">Ente</strong> to import its assets into your Immich instance.
</p>
</div>
@@ -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() {
/>
</div>
{/* Video play indicator */}
{isVideoAsset && (
{/* Video play indicator (hidden for Ente — no video preview) */}
{isVideoAsset && sharedData?.platform !== "ente" && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="rounded-full bg-black/50 p-2">
<svg className="h-4 w-4 text-white ml-0.5" viewBox="0 0 24 24" fill="currentColor">
@@ -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<DownloadedAssetPayload> => {
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<SetupResult> {
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<string, string>();
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {};
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 };
}
}
+2
View File
@@ -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());