mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
5cc81ae4b0
Replace node-fetch v2 with Node 22's built-in fetch API across the entire server.
Add undici as an explicit dependency to provide the dispatcher API needed for
DNS pinning (SSRF rebinding prevention) in ssrfGuard.ts. All seven service files
that used a plain `import fetch from 'node-fetch'` are updated to use the global.
The ssrfGuard safeFetch/createPinnedAgent is rewritten as createPinnedDispatcher
using an undici Agent, with correct handling of the `all: true` lookup callback
required by Node 18+. The collabService dynamic require() and notifications agent
option are updated to use the dispatcher pattern. Test mocks are migrated from
vi.mock('node-fetch') to vi.stubGlobal('fetch'), and streaming test fixtures are
updated to use Web ReadableStream instead of Node Readable.
Fix several bugs in the Synology and Immich photo integrations:
- pipeAsset: guard against setting headers after stream has already started
- _getSynologySession: clear stale SID and re-login when decrypt_api_key returns null
instead of propagating success(null) downstream
- _requestSynologyApi: return retrySession error (not stale session) on retry failure;
also retry on error codes 106 (timeout) and 107 (duplicate login), not only 119
- searchSynologyPhotos: fix incorrect total field type (Synology list_item returns no
total); hasMore correctly uses allItems.length === limit
- _splitPackedSynologyId: validate cache_key format before use; callers return 400
- getImmichCredentials / _getSynologyCredentials: treat null from decrypt_api_key as
a missing-credentials condition rather than casting null to string
- Synology size param: enforce allowlist ['sm', 'm', 'xl'] per API documentation
129 lines
5.9 KiB
TypeScript
129 lines
5.9 KiB
TypeScript
import express, { Request, Response } from 'express';
|
|
import { canAccessTrip } from '../../db/database';
|
|
import { authenticate } from '../../middleware/auth';
|
|
import { broadcast } from '../../websocket';
|
|
import { AuthRequest } from '../../types';
|
|
import { getClientIp } from '../../services/auditLog';
|
|
import {
|
|
getConnectionSettings,
|
|
saveImmichSettings,
|
|
testConnection,
|
|
getConnectionStatus,
|
|
browseTimeline,
|
|
searchPhotos,
|
|
streamImmichAsset,
|
|
listAlbums,
|
|
syncAlbumAssets,
|
|
getAssetInfo,
|
|
isValidAssetId,
|
|
} from '../../services/memories/immichService';
|
|
import { canAccessUserPhoto } from '../../services/memories/helpersService';
|
|
|
|
const router = express.Router();
|
|
|
|
// ── Immich Connection Settings ─────────────────────────────────────────────
|
|
|
|
router.get('/settings', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json(getConnectionSettings(authReq.user.id));
|
|
});
|
|
|
|
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { immich_url, immich_api_key } = req.body;
|
|
const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req));
|
|
if (!result.success) return res.status(400).json({ error: result.error });
|
|
if (result.warning) return res.json({ success: true, warning: result.warning });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json(await getConnectionStatus(authReq.user.id));
|
|
});
|
|
|
|
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
|
const { immich_url, immich_api_key } = req.body;
|
|
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
|
res.json(await testConnection(immich_url, immich_api_key));
|
|
});
|
|
|
|
// ── Browse Immich Library (for photo picker) ───────────────────────────────
|
|
|
|
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = await browseTimeline(authReq.user.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ buckets: result.buckets });
|
|
});
|
|
|
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { from, to } = req.body;
|
|
const result = await searchPhotos(authReq.user.id, from, to);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ assets: result.assets });
|
|
});
|
|
|
|
// ── Asset Details ──────────────────────────────────────────────────────────
|
|
|
|
router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { tripId, assetId, ownerId } = req.params;
|
|
|
|
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId));
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json(result.data);
|
|
});
|
|
|
|
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
|
|
|
router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { tripId, assetId, ownerId } = req.params;
|
|
|
|
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
await streamImmichAsset(res, authReq.user.id, assetId, 'thumbnail', Number(ownerId));
|
|
});
|
|
|
|
router.get('/assets/:tripId/:assetId/:ownerId/original', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { tripId, assetId, ownerId } = req.params;
|
|
|
|
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
await streamImmichAsset(res, authReq.user.id, assetId, 'original', Number(ownerId));
|
|
});
|
|
|
|
// ── Album Linking ──────────────────────────────────────────────────────────
|
|
|
|
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = await listAlbums(authReq.user.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ albums: result.albums });
|
|
});
|
|
|
|
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const { tripId, linkId } = req.params;
|
|
const sid = req.headers['x-socket-id'] as string;
|
|
const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ success: true, added: result.added, total: result.total });
|
|
if (result.added! > 0) {
|
|
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
}
|
|
});
|
|
|
|
export default router;
|