Files
immich-power-tools/src/pages/api/import-shared.ts
T
sai80082 9195ceb70d 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.
2026-04-24 15:54:40 +05:30

417 lines
12 KiB
TypeScript

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;
assetCount: number;
}
interface ImmichOwner {
name?: string | null;
email?: string | null;
}
interface ImmichExifInfo {
city?: string | null;
state?: string | null;
country?: string | null;
description?: string | null;
fileSizeInByte?: number | null;
}
interface ImmichAsset {
id: string;
originalFileName: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
thumbhash?: string | null;
exifInfo?: ImmichExifInfo | null;
}
interface ImmichAlbumResponse {
albumName: string;
assetCount?: number;
owner?: ImmichOwner | null;
description?: string | null;
startDate?: string | null;
endDate?: string | null;
shared?: boolean;
hasSharedLink?: boolean;
lastModifiedAssetTimestamp?: string | null;
order?: string | null;
contributorCounts?: IAlbumContributorCount[];
assets: ImmichAsset[];
}
interface ImmichSharedLinkResponse {
id: string;
key: string;
type: string;
createdAt: string;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
album?: { id: string } | null;
}
interface IImportSharedAsset {
id: string;
originalFileName: string;
type: string;
fileCreatedAt?: string | null;
localDateTime?: string | null;
description?: string | null;
location?: string | null;
thumbhash?: string | null;
fileSizeInByte?: number | null;
// Ente-specific encryption artifacts (optional)
enteFileKey?: string;
enteFileDecryptionHeader?: string;
enteThumbnailDecryptionHeader?: string;
}
interface IImportSharedAlbum {
albumName: string;
assetCount: number;
owner?: ImmichOwner | null;
description?: string | null;
startDate?: string | null;
endDate?: string | null;
shared?: boolean;
hasSharedLink?: boolean;
lastModifiedAssetTimestamp?: string | null;
order?: string | null;
contributorCounts?: IAlbumContributorCount[];
assets: IImportSharedAsset[];
}
interface IImportSharedResponse {
platform: "immich" | "nextcloud" | "ente";
link: string;
origin: string;
key: string;
sharedLink: {
id: string;
type: string;
createdAt: string;
expiresAt?: string | null;
allowUpload?: boolean;
allowDownload?: boolean;
showMetadata?: boolean;
};
album: IImportSharedAlbum | null;
// Ente-specific (optional)
enteApiBase?: string;
enteAlbumKey?: string;
}
const respondWithError = (res: NextApiResponse, status: number, message: string) => {
return res.status(status).json({ error: message });
};
const parseSharedLink = (link: string) => {
const trimmed = link.trim();
if (!trimmed) {
return null;
}
try {
const url = new URL(trimmed);
const segments = url.pathname.split("/").filter(Boolean);
const key = segments.pop();
if (!key) {
return null;
}
return { key, origin: url.origin, original: trimmed };
} catch (_err) {
return null;
}
};
const buildLocationString = (exif?: ImmichExifInfo | null) => {
if (!exif) {
return null;
}
const parts = [exif.city, exif.state, exif.country]
.filter((value): value is string => !!value && value.trim().length > 0);
return parts.length > 0 ? parts.join(", ") : null;
};
const fetchJson = async <T>(url: string) => {
const response = await fetch(url, {
headers: { accept: "application/json" },
});
if (!response.ok) {
let errorBody: { message?: string } = {};
try { errorBody = await response.json(); } catch { /* ignore */ }
if (response.status === 401 && errorBody.message === "Password required") {
const err = new Error("PASSWORD_REQUIRED");
(err as any).code = "PASSWORD_REQUIRED";
throw err;
}
if (response.status === 401 && errorBody.message === "Invalid password") {
const err = new Error("Invalid password for this shared link.");
(err as any).code = "INVALID_PASSWORD";
throw err;
}
throw new Error(`Request failed for ${url} (status ${response.status})`);
}
return (await response.json()) as T;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return respondWithError(res, 405, "Method Not Allowed");
}
const payload = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
const link = payload?.link;
const password = payload?.password;
if (!link || typeof link !== "string") {
return respondWithError(res, 400, "A shared album link is required");
}
// Detect platform from the URL
const nextcloud = detectNextcloud(link);
if (nextcloud) {
// ── Nextcloud flow ──
try {
if (!password) {
const isProtected = await checkPasswordProtection(nextcloud.baseUrl, nextcloud.token);
if (isProtected) {
return res.status(401).json({ error: "PASSWORD_REQUIRED" });
}
}
const { metadata, files } = await listNextcloudFiles(nextcloud.baseUrl, nextcloud.token, password ?? "");
const assets: IImportSharedAsset[] = files.map((file) => ({
id: file.relativePath,
originalFileName: file.fileName,
type: file.type,
fileCreatedAt: file.lastModified ?? null,
localDateTime: null,
description: null,
location: null,
thumbhash: file.blurhash ?? null,
fileSizeInByte: file.size || null,
}));
// Derive date range from file modification dates
const dates = files
.map((f) => f.lastModified ? new Date(f.lastModified).getTime() : NaN)
.filter((t) => !Number.isNaN(t));
const startDate = dates.length > 0 ? new Date(Math.min(...dates)).toISOString() : null;
const endDate = dates.length > 0 ? new Date(Math.max(...dates)).toISOString() : null;
const albumName = metadata.displayName || "Nextcloud Share";
const owner = metadata.ownerId
? { name: metadata.ownerId, email: null }
: null;
const responseBody: IImportSharedResponse = {
platform: "nextcloud",
link: link.trim(),
origin: nextcloud.baseUrl,
key: nextcloud.token,
sharedLink: {
id: nextcloud.token,
type: "FOLDER",
createdAt: new Date().toISOString(),
expiresAt: null,
allowUpload: false,
allowDownload: true,
showMetadata: true,
},
album: {
albumName,
assetCount: assets.length,
owner,
description: null,
startDate,
endDate,
shared: true,
hasSharedLink: true,
lastModifiedAssetTimestamp: endDate,
order: null,
contributorCounts: [],
assets,
},
};
return res.status(200).json(responseBody);
} catch (error: any) {
if (error?.code === "PASSWORD_REQUIRED") {
return res.status(401).json({ error: "PASSWORD_REQUIRED" });
}
console.error("Nextcloud import shared error", error);
return respondWithError(res, 500, error?.message ?? "Failed to fetch Nextcloud share");
}
}
// ── 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, Nextcloud, or Ente share URLs.");
}
const authQuery = password
? `key=${parsed.key}&password=${encodeURIComponent(password)}`
: `key=${parsed.key}`;
try {
const sharedLink = await fetchJson<ImmichSharedLinkResponse>(
`${parsed.origin}/api/shared-links/me?${authQuery}`
);
const albumId = sharedLink.album?.id;
let albumResult: IImportSharedAlbum | null = null;
if (albumId) {
const album = await fetchJson<ImmichAlbumResponse>(
`${parsed.origin}/api/albums/${albumId}?${authQuery}&withoutAssets=false`
);
const assets = (album.assets || []).map((asset) => ({
id: asset.id,
originalFileName: asset.originalFileName,
type: asset.type,
fileCreatedAt: asset.fileCreatedAt ?? null,
localDateTime: asset.localDateTime ?? null,
description: asset.exifInfo?.description ?? null,
location: buildLocationString(asset.exifInfo),
thumbhash: asset.thumbhash ?? null,
fileSizeInByte: asset.exifInfo?.fileSizeInByte ?? null,
} satisfies IImportSharedAsset));
albumResult = {
albumName: album.albumName,
assetCount: album.assetCount ?? assets.length,
owner: album.owner ?? null,
description: album.description ?? null,
startDate: album.startDate ?? null,
endDate: album.endDate ?? null,
shared: album.shared ?? false,
hasSharedLink: album.hasSharedLink ?? false,
lastModifiedAssetTimestamp: album.lastModifiedAssetTimestamp ?? null,
order: album.order ?? null,
contributorCounts: album.contributorCounts ?? [],
assets,
} satisfies IImportSharedAlbum;
}
const responseBody: IImportSharedResponse = {
platform: "immich",
link: parsed.original,
origin: parsed.origin,
key: parsed.key,
sharedLink: {
id: sharedLink.id,
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt ?? null,
allowUpload: sharedLink.allowUpload ?? false,
allowDownload: sharedLink.allowDownload ?? false,
showMetadata: sharedLink.showMetadata ?? false,
},
album: albumResult,
};
return res.status(200).json(responseBody);
} catch (error: any) {
if (error?.code === "PASSWORD_REQUIRED") {
return res.status(401).json({ error: "PASSWORD_REQUIRED" });
}
if (error?.code === "INVALID_PASSWORD") {
return res.status(401).json({ error: error.message });
}
console.error("Import shared error", error);
return respondWithError(res, 500, error?.message ?? "Failed to import shared album");
}
}