mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
146 lines
5.5 KiB
TypeScript
146 lines
5.5 KiB
TypeScript
import path from 'path';
|
|
import fs from 'fs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { readEnv } from '../app-config';
|
|
import { safeFetch } from '../utils/ssrfGuard';
|
|
import { db } from '../db/database';
|
|
import { decrypt_api_key } from './apiKeyCrypto';
|
|
|
|
interface UnsplashSearchResponse {
|
|
results?: {
|
|
id: string;
|
|
urls?: { regular?: string; small?: string; thumb?: string };
|
|
description?: string | null;
|
|
alt_description?: string | null;
|
|
user?: { name?: string };
|
|
links?: { html?: string };
|
|
}[];
|
|
errors?: string[];
|
|
error?: string;
|
|
}
|
|
|
|
export interface UnsplashPhoto {
|
|
id: string;
|
|
url: string;
|
|
thumb: string;
|
|
description: string | null;
|
|
photographer: string | null;
|
|
link: string | null;
|
|
}
|
|
|
|
/**
|
|
* Resolve an Unsplash access key for a request, in precedence order:
|
|
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
|
|
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
|
|
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
|
|
* in which case the search falls back to the unauthenticated endpoint.
|
|
*/
|
|
export function getUnsplashKey(userId: number): string | null {
|
|
const env_key = readEnv().integrations.unsplashAccessKey;
|
|
if (env_key) return env_key;
|
|
const user = db.prepare('SELECT unsplash_api_key FROM users WHERE id = ?').get(userId) as { unsplash_api_key: string | null } | undefined;
|
|
const user_key = decrypt_api_key(user?.unsplash_api_key);
|
|
if (user_key) return user_key;
|
|
const admin = db.prepare("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1").get() as { unsplash_api_key: string } | undefined;
|
|
return decrypt_api_key(admin?.unsplash_api_key) || null;
|
|
}
|
|
|
|
export async function searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {
|
|
const trimmed = query.trim();
|
|
if (!trimmed) {
|
|
return { error: 'Search query is required', status: 400 };
|
|
}
|
|
|
|
const params = new URLSearchParams({
|
|
page: '1',
|
|
query: trimmed,
|
|
per_page: String(perPage),
|
|
});
|
|
// With an access key, use Unsplash's official, authenticated API — datacenter
|
|
// (VPS) IPs get blocked on the unauthenticated web endpoint (#1449). Without
|
|
// one, fall back to the web endpoint so zero-config search still works.
|
|
const response = accessKey
|
|
? await fetch(`https://api.unsplash.com/search/photos?${params.toString()}`, {
|
|
headers: {
|
|
Authorization: `Client-ID ${accessKey}`,
|
|
'Accept-Version': 'v1',
|
|
},
|
|
})
|
|
: await fetch(`https://unsplash.com/napi/search/photos?${params.toString()}`, {
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0',
|
|
Accept: '*/*',
|
|
'Accept-Language': 'en-US',
|
|
Referer: `https://unsplash.com/s/photos/${encodeURIComponent(trimmed)}`,
|
|
'client-geo-region': 'global',
|
|
'Sec-Fetch-Dest': 'empty',
|
|
'Sec-Fetch-Site': 'same-origin',
|
|
},
|
|
});
|
|
let data: UnsplashSearchResponse;
|
|
try {
|
|
data = await response.json() as UnsplashSearchResponse;
|
|
} catch {
|
|
return { error: 'Unsplash search unavailable', status: response.ok ? 502 : response.status };
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return { error: data.errors?.[0] || data.error || 'Unsplash search unavailable', status: response.status };
|
|
}
|
|
|
|
const photos: UnsplashPhoto[] = (data.results || [])
|
|
.map((p) => ({
|
|
id: p.id,
|
|
url: p.urls?.regular || '',
|
|
thumb: p.urls?.small || p.urls?.thumb || p.urls?.regular || '',
|
|
description: p.description || p.alt_description || null,
|
|
photographer: p.user?.name || null,
|
|
link: p.links?.html || null,
|
|
}))
|
|
.filter((p) => p.url && p.thumb)
|
|
.slice(0, perPage);
|
|
|
|
return { photos };
|
|
}
|
|
|
|
const UNSPLASH_IMAGE_HOST = 'images.unsplash.com';
|
|
const MAX_COVER_BYTES = 15 * 1024 * 1024;
|
|
const COVER_EXT_BY_TYPE: Record<string, string> = {
|
|
'image/jpeg': '.jpg',
|
|
'image/png': '.png',
|
|
'image/webp': '.webp',
|
|
'image/gif': '.gif',
|
|
};
|
|
|
|
/** True when a cover_image value is an Unsplash CDN hot-link we should internalise. */
|
|
export function isUnsplashCoverUrl(value: unknown): value is string {
|
|
if (typeof value !== 'string') return false;
|
|
try {
|
|
return new URL(value).hostname.toLowerCase() === UNSPLASH_IMAGE_HOST;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download a chosen Unsplash cover from its CDN into destDir so the cover is
|
|
* stored locally (offline + CDN link-rot safe) instead of hot-linked. Only the
|
|
* Unsplash image CDN host is accepted, and the request goes through the SSRF
|
|
* guard. Returns the saved filename. Throws on a non-Unsplash host, a failed
|
|
* download, an unsupported content type, or an oversized image.
|
|
*/
|
|
export async function saveUnsplashCover(url: string, destDir: string): Promise<string> {
|
|
if (!isUnsplashCoverUrl(url)) throw new Error('Not an Unsplash image URL');
|
|
const res = await safeFetch(url);
|
|
if (!res.ok) throw new Error(`Unsplash image download failed (HTTP ${res.status})`);
|
|
const type = (res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
|
const ext = COVER_EXT_BY_TYPE[type];
|
|
if (!ext) throw new Error(`Unsupported cover image type: ${type || 'unknown'}`);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
if (buf.byteLength > MAX_COVER_BYTES) throw new Error('Cover image too large');
|
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
const filename = `${uuidv4()}${ext}`;
|
|
fs.writeFileSync(path.join(destDir, filename), buf);
|
|
return filename;
|
|
}
|