@@ -89,7 +89,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
const loadAlbumLinks = async () => {
|
const loadAlbumLinks = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
|
const res = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`)
|
||||||
setAlbumLinks(res.data.links || [])
|
setAlbumLinks(res.data.links || [])
|
||||||
} catch { setAlbumLinks([]) }
|
} catch { setAlbumLinks([]) }
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
if (!provider) return
|
if (!provider) return
|
||||||
setAlbumsLoading(true)
|
setAlbumsLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/integrations/${provider}/albums`)
|
const res = await apiClient.get(`/integrations/memories/${provider}/albums`)
|
||||||
setAlbums(res.data.albums || [])
|
setAlbums(res.data.albums || [])
|
||||||
} catch {
|
} catch {
|
||||||
setAlbums([])
|
setAlbums([])
|
||||||
@@ -120,7 +120,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/integrations/memories/trips/${tripId}/album-links`, {
|
await apiClient.post(`/integrations/memories/unified/trips/${tripId}/album-links`, {
|
||||||
album_id: albumId,
|
album_id: albumId,
|
||||||
album_name: albumName,
|
album_name: albumName,
|
||||||
provider: selectedProvider,
|
provider: selectedProvider,
|
||||||
@@ -128,7 +128,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setShowAlbumPicker(false)
|
setShowAlbumPicker(false)
|
||||||
await loadAlbumLinks()
|
await loadAlbumLinks()
|
||||||
// Auto-sync after linking
|
// Auto-sync after linking
|
||||||
const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
|
const linksRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/album-links`)
|
||||||
const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
|
const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
|
||||||
if (newLink) await syncAlbum(newLink.id)
|
if (newLink) await syncAlbum(newLink.id)
|
||||||
} catch { toast.error(t('memories.error.linkAlbum')) }
|
} catch { toast.error(t('memories.error.linkAlbum')) }
|
||||||
@@ -136,7 +136,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
const unlinkAlbum = async (linkId: number) => {
|
const unlinkAlbum = async (linkId: number) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`)
|
await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/album-links/${linkId}`)
|
||||||
loadAlbumLinks()
|
loadAlbumLinks()
|
||||||
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
if (!targetProvider) return
|
if (!targetProvider) return
|
||||||
setSyncing(linkId)
|
setSyncing(linkId)
|
||||||
try {
|
try {
|
||||||
await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`)
|
await apiClient.post(`/integrations/memories/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`)
|
||||||
await loadAlbumLinks()
|
await loadAlbumLinks()
|
||||||
await loadPhotos()
|
await loadPhotos()
|
||||||
} catch { toast.error(t('memories.error.syncAlbum')) }
|
} catch { toast.error(t('memories.error.syncAlbum')) }
|
||||||
@@ -175,7 +175,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
const loadPhotos = async () => {
|
const loadPhotos = async () => {
|
||||||
try {
|
try {
|
||||||
const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`)
|
const photosRes = await apiClient.get(`/integrations/memories/unified/trips/${tripId}/photos`)
|
||||||
setTripPhotos(photosRes.data.photos || [])
|
setTripPhotos(photosRes.data.photos || [])
|
||||||
} catch {
|
} catch {
|
||||||
setTripPhotos([])
|
setTripPhotos([])
|
||||||
@@ -257,7 +257,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setPickerPhotos([])
|
setPickerPhotos([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await apiClient.post(`/integrations/${provider.id}/search`, {
|
const res = await apiClient.post(`/integrations/memories/${provider.id}/search`, {
|
||||||
from: useDate && startDate ? startDate : undefined,
|
from: useDate && startDate ? startDate : undefined,
|
||||||
to: useDate && endDate ? endDate : undefined,
|
to: useDate && endDate ? endDate : undefined,
|
||||||
})
|
})
|
||||||
@@ -296,7 +296,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
groupedByProvider.set(provider, list)
|
groupedByProvider.set(provider, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, {
|
await apiClient.post(`/integrations/memories/unified/trips/${tripId}/photos`, {
|
||||||
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
|
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
|
||||||
shared: true,
|
shared: true,
|
||||||
})
|
})
|
||||||
@@ -310,7 +310,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
const removePhoto = async (photo: TripPhoto) => {
|
const removePhoto = async (photo: TripPhoto) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, {
|
await apiClient.delete(`/integrations/memories/unified/trips/${tripId}/photos`, {
|
||||||
data: {
|
data: {
|
||||||
asset_id: photo.asset_id,
|
asset_id: photo.asset_id,
|
||||||
provider: photo.provider,
|
provider: photo.provider,
|
||||||
@@ -324,7 +324,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
|
|
||||||
const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
|
const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, {
|
await apiClient.put(`/integrations/memories/unified/trips/${tripId}/photos/sharing`, {
|
||||||
shared,
|
shared,
|
||||||
asset_id: photo.asset_id,
|
asset_id: photo.asset_id,
|
||||||
provider: photo.provider,
|
provider: photo.provider,
|
||||||
@@ -338,7 +338,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const thumbnailBaseUrl = (photo: TripPhoto) =>
|
const thumbnailBaseUrl = (photo: TripPhoto) =>
|
||||||
`/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail`
|
`/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/thumbnail`
|
||||||
|
|
||||||
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
|
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
|
||||||
|
|
||||||
@@ -598,7 +598,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
outlineOffset: -3,
|
outlineOffset: -3,
|
||||||
}}>
|
}}>
|
||||||
<ProviderImg baseUrl={`/api/integrations/${asset.provider}/assets//${tripId}/${asset.id}/${currentUser!.id}/thumbnail`} provider={asset.provider} loading="lazy"
|
<ProviderImg baseUrl={`/api/integrations/memories/${asset.provider}/assets/${tripId}/${asset.id}/${currentUser!.id}/thumbnail`} provider={asset.provider} loading="lazy"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -778,9 +778,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||||
setLightboxOriginalSrc('')
|
setLightboxOriginalSrc('')
|
||||||
fetchImageAsBlob(`/api/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/original`).then(setLightboxOriginalSrc)
|
fetchImageAsBlob(`/api/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/original`).then(setLightboxOriginalSrc)
|
||||||
setLightboxInfoLoading(true)
|
setLightboxInfoLoading(true)
|
||||||
apiClient.get(`/integrations/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/info`)
|
apiClient.get(`/integrations/memories/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/info`)
|
||||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,12 @@ import backupRoutes from './routes/backup';
|
|||||||
import oidcRoutes from './routes/oidc';
|
import oidcRoutes from './routes/oidc';
|
||||||
import vacayRoutes from './routes/vacay';
|
import vacayRoutes from './routes/vacay';
|
||||||
import atlasRoutes from './routes/atlas';
|
import atlasRoutes from './routes/atlas';
|
||||||
import immichRoutes from './routes/immich';
|
import memoriesRoutes from './routes/memories/unified';
|
||||||
import synologyRoutes from './routes/synology';
|
|
||||||
import memoriesRoutes from './routes/memories';
|
|
||||||
import notificationRoutes from './routes/notifications';
|
import notificationRoutes from './routes/notifications';
|
||||||
import shareRoutes from './routes/share';
|
import shareRoutes from './routes/share';
|
||||||
import { mcpHandler } from './mcp';
|
import { mcpHandler } from './mcp';
|
||||||
import { Addon } from './types';
|
import { Addon } from './types';
|
||||||
|
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||||
|
|
||||||
export function createApp(): express.Application {
|
export function createApp(): express.Application {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -196,11 +195,11 @@ export function createApp(): express.Application {
|
|||||||
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
|
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
|
||||||
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||||
const providers = db.prepare(`
|
const providers = db.prepare(`
|
||||||
SELECT id, name, icon, enabled, config, sort_order
|
SELECT id, name, icon, enabled, sort_order
|
||||||
FROM photo_providers
|
FROM photo_providers
|
||||||
WHERE enabled = 1
|
WHERE enabled = 1
|
||||||
ORDER BY sort_order, id
|
ORDER BY sort_order, id
|
||||||
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; config: string; sort_order: number }>;
|
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
|
||||||
const fields = db.prepare(`
|
const fields = db.prepare(`
|
||||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||||
FROM photo_provider_fields
|
FROM photo_provider_fields
|
||||||
@@ -234,7 +233,7 @@ export function createApp(): express.Application {
|
|||||||
type: 'photo_provider',
|
type: 'photo_provider',
|
||||||
icon: p.icon,
|
icon: p.icon,
|
||||||
enabled: !!p.enabled,
|
enabled: !!p.enabled,
|
||||||
config: JSON.parse(p.config || '{}'),
|
config: getPhotoProviderConfig(p.id),
|
||||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||||
key: f.field_key,
|
key: f.field_key,
|
||||||
label: f.label,
|
label: f.label,
|
||||||
@@ -255,8 +254,6 @@ export function createApp(): express.Application {
|
|||||||
app.use('/api/addons/vacay', vacayRoutes);
|
app.use('/api/addons/vacay', vacayRoutes);
|
||||||
app.use('/api/addons/atlas', atlasRoutes);
|
app.use('/api/addons/atlas', atlasRoutes);
|
||||||
app.use('/api/integrations/memories', memoriesRoutes);
|
app.use('/api/integrations/memories', memoriesRoutes);
|
||||||
app.use('/api/integrations/immich', immichRoutes);
|
|
||||||
app.use('/api/integrations/synologyphotos', synologyRoutes);
|
|
||||||
app.use('/api/maps', mapsRoutes);
|
app.use('/api/maps', mapsRoutes);
|
||||||
app.use('/api/weather', weatherRoutes);
|
app.use('/api/weather', weatherRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
|||||||
@@ -643,14 +643,13 @@ function runMigrations(db: Database.Database): void {
|
|||||||
// Seed Synology Photos provider and fields in existing databases
|
// Seed Synology Photos provider and fields in existing databases
|
||||||
try {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order)
|
INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
name = excluded.name,
|
name = excluded.name,
|
||||||
description = excluded.description,
|
description = excluded.description,
|
||||||
icon = excluded.icon,
|
icon = excluded.icon,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
config = excluded.config,
|
|
||||||
sort_order = excluded.sort_order
|
sort_order = excluded.sort_order
|
||||||
`).run(
|
`).run(
|
||||||
'synologyphotos',
|
'synologyphotos',
|
||||||
@@ -658,12 +657,6 @@ function runMigrations(db: Database.Database): void {
|
|||||||
'Synology Photos integration with separate account settings',
|
'Synology Photos integration with separate account settings',
|
||||||
'Image',
|
'Image',
|
||||||
0,
|
0,
|
||||||
JSON.stringify({
|
|
||||||
settings_get: '/integrations/synologyphotos/settings',
|
|
||||||
settings_put: '/integrations/synologyphotos/settings',
|
|
||||||
status_get: '/integrations/synologyphotos/status',
|
|
||||||
test_post: '/integrations/synologyphotos/test',
|
|
||||||
}),
|
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -691,6 +684,23 @@ function runMigrations(db: Database.Database): void {
|
|||||||
if (!err.message?.includes('no such table')) throw err;
|
if (!err.message?.includes('no such table')) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
() => {
|
||||||
|
// Remove the stored config column from photo_providers now that it is generated from provider id.
|
||||||
|
const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>;
|
||||||
|
const names = new Set(columns.map(c => c.name));
|
||||||
|
if (!names.has('config')) return;
|
||||||
|
|
||||||
|
db.exec('ALTER TABLE photo_providers DROP COLUMN config');
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
|
||||||
|
const names = new Set(columns.map(c => c.name));
|
||||||
|
if (names.has('asset_id') && !names.has('immich_asset_id')) return;
|
||||||
|
db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id');
|
||||||
|
db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
|
||||||
|
db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
|
||||||
|
db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id');
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ function createTables(db: Database.Database): void {
|
|||||||
mfa_enabled INTEGER DEFAULT 0,
|
mfa_enabled INTEGER DEFAULT 0,
|
||||||
mfa_secret TEXT,
|
mfa_secret TEXT,
|
||||||
mfa_backup_codes TEXT,
|
mfa_backup_codes TEXT,
|
||||||
|
immich_url TEXT,
|
||||||
|
immich_access_token TEXT,
|
||||||
synology_url TEXT,
|
synology_url TEXT,
|
||||||
synology_username TEXT,
|
synology_username TEXT,
|
||||||
synology_password TEXT,
|
synology_password TEXT,
|
||||||
@@ -166,6 +168,7 @@ function createTables(db: Database.Database): void {
|
|||||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||||
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
|
accommodation_id TEXT,
|
||||||
reservation_time TEXT,
|
reservation_time TEXT,
|
||||||
reservation_end_time TEXT,
|
reservation_end_time TEXT,
|
||||||
location TEXT,
|
location TEXT,
|
||||||
@@ -232,7 +235,6 @@ function createTables(db: Database.Database): void {
|
|||||||
description TEXT,
|
description TEXT,
|
||||||
icon TEXT DEFAULT 'Image',
|
icon TEXT DEFAULT 'Image',
|
||||||
enabled INTEGER DEFAULT 0,
|
enabled INTEGER DEFAULT 0,
|
||||||
config TEXT DEFAULT '{}',
|
|
||||||
sort_order INTEGER DEFAULT 0
|
sort_order INTEGER DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -101,12 +101,6 @@ function seedAddons(db: Database.Database): void {
|
|||||||
icon: 'Image',
|
icon: 'Image',
|
||||||
enabled: 0,
|
enabled: 0,
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
config: JSON.stringify({
|
|
||||||
settings_get: '/integrations/immich/settings',
|
|
||||||
settings_put: '/integrations/immich/settings',
|
|
||||||
status_get: '/integrations/immich/status',
|
|
||||||
test_post: '/integrations/immich/test',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'synologyphotos',
|
id: 'synologyphotos',
|
||||||
@@ -115,16 +109,10 @@ function seedAddons(db: Database.Database): void {
|
|||||||
icon: 'Image',
|
icon: 'Image',
|
||||||
enabled: 0,
|
enabled: 0,
|
||||||
sort_order: 1,
|
sort_order: 1,
|
||||||
config: JSON.stringify({
|
|
||||||
settings_get: '/integrations/synologyphotos/settings',
|
|
||||||
settings_put: '/integrations/synologyphotos/settings',
|
|
||||||
status_get: '/integrations/synologyphotos/status',
|
|
||||||
test_post: '/integrations/synologyphotos/test',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, config, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
const insertProvider = db.prepare('INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order);
|
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.sort_order);
|
||||||
|
|
||||||
const providerFields = [
|
const providerFields = [
|
||||||
{ provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
|
{ provider_id: 'immich', field_key: 'immich_url', label: 'Immich URL', input_type: 'url', placeholder: 'https://immich.example.com', required: 1, secret: 0, settings_key: 'immich_url', payload_key: 'immich_url', sort_order: 0 },
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import express, { Request, Response } from 'express';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
|
||||||
import { broadcast } from '../websocket';
|
|
||||||
import { AuthRequest } from '../types';
|
|
||||||
import {
|
|
||||||
listTripPhotos,
|
|
||||||
listTripAlbumLinks,
|
|
||||||
createTripAlbumLink,
|
|
||||||
removeAlbumLink,
|
|
||||||
addTripPhotos,
|
|
||||||
removeTripPhoto,
|
|
||||||
setTripPhotoSharing,
|
|
||||||
notifySharedTripPhotos,
|
|
||||||
} from '../services/memoriesService';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
|
|
||||||
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const result = listTripPhotos(tripId, authReq.user.id);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
res.json({ photos: result.photos });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const result = listTripAlbumLinks(tripId, authReq.user.id);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
res.json({ links: result.links });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId, linkId } = req.params;
|
|
||||||
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
res.json({ success: true });
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const result = addTripPhotos(
|
|
||||||
tripId,
|
|
||||||
authReq.user.id,
|
|
||||||
req.body?.shared,
|
|
||||||
req.body?.selections,
|
|
||||||
req.body?.provider,
|
|
||||||
req.body?.asset_ids,
|
|
||||||
);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
|
|
||||||
res.json({ success: true, added: result.added });
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
||||||
|
|
||||||
if (result.shared && result.added > 0) {
|
|
||||||
void notifySharedTripPhotos(
|
|
||||||
tripId,
|
|
||||||
authReq.user.id,
|
|
||||||
authReq.user.username || authReq.user.email,
|
|
||||||
result.added,
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const result = removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
res.json({ success: true });
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/trips/:tripId/photos/sharing', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const result = setTripPhotoSharing(
|
|
||||||
tripId,
|
|
||||||
authReq.user.id,
|
|
||||||
req.body?.provider,
|
|
||||||
req.body?.asset_id,
|
|
||||||
req.body?.shared,
|
|
||||||
);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
res.json({ success: true });
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId } = req.params;
|
|
||||||
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
|
|
||||||
if ('error' in result) return res.status(result.status).json({ error: result.error });
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import express, { Request, Response, NextFunction } from 'express';
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
import { db, canAccessTrip } from '../db/database';
|
import { db, canAccessTrip } from '../../db/database';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../../middleware/auth';
|
||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../../websocket';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../../types';
|
||||||
import { consumeEphemeralToken } from '../services/ephemeralTokens';
|
import { consumeEphemeralToken } from '../../services/ephemeralTokens';
|
||||||
import { getClientIp } from '../services/auditLog';
|
import { getClientIp } from '../../services/auditLog';
|
||||||
import {
|
import {
|
||||||
getConnectionSettings,
|
getConnectionSettings,
|
||||||
saveImmichSettings,
|
saveImmichSettings,
|
||||||
@@ -14,12 +14,11 @@ import {
|
|||||||
searchPhotos,
|
searchPhotos,
|
||||||
proxyThumbnail,
|
proxyThumbnail,
|
||||||
proxyOriginal,
|
proxyOriginal,
|
||||||
isValidAssetId,
|
|
||||||
listAlbums,
|
listAlbums,
|
||||||
syncAlbumAssets,
|
syncAlbumAssets,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
} from '../services/immichService';
|
} from '../../services/memories/immichService';
|
||||||
import { canAccessUserPhoto } from '../services/memoriesService';
|
import { canAccessUserPhoto } from '../../services/memories/helpersService';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
126
server/src/routes/memories/synology.ts
Normal file
126
server/src/routes/memories/synology.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { authenticate } from '../../middleware/auth';
|
||||||
|
import { AuthRequest } from '../../types';
|
||||||
|
import {
|
||||||
|
getSynologySettings,
|
||||||
|
updateSynologySettings,
|
||||||
|
getSynologyStatus,
|
||||||
|
testSynologyConnection,
|
||||||
|
listSynologyAlbums,
|
||||||
|
syncSynologyAlbumLink,
|
||||||
|
searchSynologyPhotos,
|
||||||
|
getSynologyAssetInfo,
|
||||||
|
streamSynologyAsset,
|
||||||
|
} from '../../services/memories/synologyService';
|
||||||
|
import { canAccessUserPhoto, handleServiceResult, fail } from '../../services/memories/helpersService';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function _parseStringBodyField(value: unknown): string {
|
||||||
|
return String(value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _parseNumberBodyField(value: unknown, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/settings', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
handleServiceResult(res, await getSynologySettings(authReq.user.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const body = req.body as Record<string, unknown>;
|
||||||
|
const synology_url = _parseStringBodyField(body.synology_url);
|
||||||
|
const synology_username = _parseStringBodyField(body.synology_username);
|
||||||
|
const synology_password = _parseStringBodyField(body.synology_password);
|
||||||
|
|
||||||
|
if (!synology_url || !synology_username) {
|
||||||
|
handleServiceResult(res, fail('URL and username are required', 400));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
handleServiceResult(res, await getSynologyStatus(authReq.user.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const body = req.body as Record<string, unknown>;
|
||||||
|
const synology_url = _parseStringBodyField(body.synology_url);
|
||||||
|
const synology_username = _parseStringBodyField(body.synology_username);
|
||||||
|
const synology_password = _parseStringBodyField(body.synology_password);
|
||||||
|
|
||||||
|
if (!synology_url || !synology_username || !synology_password) {
|
||||||
|
handleServiceResult(res, fail('URL, username, and password are required', 400));
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
handleServiceResult(res, await testSynologyConnection(synology_url, synology_username, synology_password));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
handleServiceResult(res, await listSynologyAlbums(authReq.user.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, linkId } = req.params;
|
||||||
|
|
||||||
|
handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const body = req.body as Record<string, unknown>;
|
||||||
|
const from = _parseStringBodyField(body.from);
|
||||||
|
const to = _parseStringBodyField(body.to);
|
||||||
|
const offset = _parseNumberBodyField(body.offset, 0);
|
||||||
|
const limit = _parseNumberBodyField(body.limit, 100);
|
||||||
|
|
||||||
|
handleServiceResult(res, await searchSynologyPhotos(
|
||||||
|
authReq.user.id,
|
||||||
|
from || undefined,
|
||||||
|
to || undefined,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, photoId, ownerId } = req.params;
|
||||||
|
|
||||||
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||||
|
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, photoId, ownerId, kind } = req.params;
|
||||||
|
const { size = "sm" } = req.query;
|
||||||
|
|
||||||
|
if (kind !== 'thumbnail' && kind !== 'original') {
|
||||||
|
handleServiceResult(res, fail('Invalid asset kind', 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
||||||
|
handleServiceResult(res, fail('You don\'t have access to this photo', 403));
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size));
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
102
server/src/routes/memories/unified.ts
Normal file
102
server/src/routes/memories/unified.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import express, { Request, Response } from 'express';
|
||||||
|
import { authenticate } from '../../middleware/auth';
|
||||||
|
import { AuthRequest } from '../../types';
|
||||||
|
import {
|
||||||
|
listTripPhotos,
|
||||||
|
listTripAlbumLinks,
|
||||||
|
createTripAlbumLink,
|
||||||
|
removeAlbumLink,
|
||||||
|
addTripPhotos,
|
||||||
|
removeTripPhoto,
|
||||||
|
setTripPhotoSharing,
|
||||||
|
} from '../../services/memories/unifiedService';
|
||||||
|
import immichRouter from './immich';
|
||||||
|
import synologyRouter from './synology';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use('/immich', immichRouter);
|
||||||
|
router.use('/synologyphotos', synologyRouter);
|
||||||
|
|
||||||
|
//------------------------------------------------
|
||||||
|
// routes for managing photos linked to trip
|
||||||
|
|
||||||
|
router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const result = listTripPhotos(tripId, authReq.user.id);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
res.json({ photos: result.data });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const sid = req.headers['x-socket-id'] as string;
|
||||||
|
|
||||||
|
const shared = req.body?.shared === undefined ? true : !!req.body?.shared;
|
||||||
|
const result = await addTripPhotos(
|
||||||
|
tripId,
|
||||||
|
authReq.user.id,
|
||||||
|
shared,
|
||||||
|
req.body?.selections || [],
|
||||||
|
sid,
|
||||||
|
);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
|
||||||
|
res.json({ success: true, added: result.data.added });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const result = await setTripPhotoSharing(
|
||||||
|
tripId,
|
||||||
|
authReq.user.id,
|
||||||
|
req.body?.provider,
|
||||||
|
req.body?.asset_id,
|
||||||
|
req.body?.shared,
|
||||||
|
);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const result = await removeTripPhoto(tripId, authReq.user.id, req.body?.provider, req.body?.asset_id);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
//------------------------------
|
||||||
|
// routes for managing album links
|
||||||
|
|
||||||
|
router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const result = listTripAlbumLinks(tripId, authReq.user.id);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
res.json({ links: result.data });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
const { tripId, linkId } = req.params;
|
||||||
|
const result = removeAlbumLink(tripId, linkId, authReq.user.id);
|
||||||
|
if ('error' in result) return res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import express, { Request, Response } from 'express';
|
|
||||||
import { authenticate } from '../middleware/auth';
|
|
||||||
import { broadcast } from '../websocket';
|
|
||||||
import { AuthRequest } from '../types';
|
|
||||||
import {
|
|
||||||
getSynologySettings,
|
|
||||||
updateSynologySettings,
|
|
||||||
getSynologyStatus,
|
|
||||||
testSynologyConnection,
|
|
||||||
listSynologyAlbums,
|
|
||||||
syncSynologyAlbumLink,
|
|
||||||
searchSynologyPhotos,
|
|
||||||
getSynologyAssetInfo,
|
|
||||||
pipeSynologyProxy,
|
|
||||||
streamSynologyAsset,
|
|
||||||
handleSynologyError,
|
|
||||||
SynologyServiceError,
|
|
||||||
} from '../services/synologyService';
|
|
||||||
import { canAccessUserPhoto } from '../services/memoriesService';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
function parseStringBodyField(value: unknown): string {
|
|
||||||
return String(value ?? '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseNumberBodyField(value: unknown, fallback: number): number {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/settings', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
try {
|
|
||||||
res.json(await getSynologySettings(authReq.user.id));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
handleSynologyError(res, err, 'Failed to load settings');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/settings', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const body = req.body as Record<string, unknown>;
|
|
||||||
const synology_url = parseStringBodyField(body.synology_url);
|
|
||||||
const synology_username = parseStringBodyField(body.synology_username);
|
|
||||||
const synology_password = parseStringBodyField(body.synology_password);
|
|
||||||
|
|
||||||
if (!synology_url || !synology_username) {
|
|
||||||
return handleSynologyError(res, new SynologyServiceError(400, 'URL and username are required'), 'Missing required fields');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err: unknown) {
|
|
||||||
handleSynologyError(res, err, 'Failed to save settings');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/status', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
res.json(await getSynologyStatus(authReq.user.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const body = req.body as Record<string, unknown>;
|
|
||||||
const synology_url = parseStringBodyField(body.synology_url);
|
|
||||||
const synology_username = parseStringBodyField(body.synology_username);
|
|
||||||
const synology_password = parseStringBodyField(body.synology_password);
|
|
||||||
|
|
||||||
if (!synology_url || !synology_username || !synology_password) {
|
|
||||||
return handleSynologyError(res, new SynologyServiceError(400, 'URL, username and password are required'), 'Missing required fields');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(await testSynologyConnection(synology_url, synology_username, synology_password));
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
try {
|
|
||||||
res.json(await listSynologyAlbums(authReq.user.id));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
handleSynologyError(res, err, 'Could not reach Synology');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId, linkId } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await syncSynologyAlbumLink(authReq.user.id, tripId, linkId);
|
|
||||||
res.json({ success: true, ...result });
|
|
||||||
if (result.added > 0) {
|
|
||||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
handleSynologyError(res, err, 'Could not reach Synology');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/search', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const body = req.body as Record<string, unknown>;
|
|
||||||
const from = parseStringBodyField(body.from);
|
|
||||||
const to = parseStringBodyField(body.to);
|
|
||||||
const offset = parseNumberBodyField(body.offset, 0);
|
|
||||||
const limit = parseNumberBodyField(body.limit, 300);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await searchSynologyPhotos(
|
|
||||||
authReq.user.id,
|
|
||||||
from || undefined,
|
|
||||||
to || undefined,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
);
|
|
||||||
res.json(result);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
handleSynologyError(res, err, 'Could not reach Synology');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId, photoId, ownerId } = req.params;
|
|
||||||
|
|
||||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
|
||||||
return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
res.json(await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId)));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
handleSynologyError(res, err, 'Could not reach Synology');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/assets/:tripId/:photoId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId, photoId, ownerId } = req.params;
|
|
||||||
const { size = 'sm' } = req.query;
|
|
||||||
|
|
||||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
|
||||||
return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'thumbnail', String(size));
|
|
||||||
await pipeSynologyProxy(res, proxy);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSynologyError(res, err, 'Proxy error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/assets/:tripId/:photoId/:ownerId/original', authenticate, async (req: Request, res: Response) => {
|
|
||||||
const authReq = req as AuthRequest;
|
|
||||||
const { tripId, photoId, ownerId } = req.params;
|
|
||||||
|
|
||||||
if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) {
|
|
||||||
return handleSynologyError(res, new SynologyServiceError(403, 'You don\'t have access to this photo'), 'Access denied');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const proxy = await streamSynologyAsset(authReq.user.id, Number(ownerId), photoId, 'original');
|
|
||||||
await pipeSynologyProxy(res, proxy);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleSynologyError(res, err, 'Proxy error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -9,6 +9,7 @@ import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
|||||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||||
import { revokeUserSessions } from '../mcp';
|
import { revokeUserSessions } from '../mcp';
|
||||||
import { validatePassword } from './passwordPolicy';
|
import { validatePassword } from './passwordPolicy';
|
||||||
|
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -466,10 +467,10 @@ export function deleteTemplateItem(itemId: string) {
|
|||||||
export function listAddons() {
|
export function listAddons() {
|
||||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||||
const providers = db.prepare(`
|
const providers = db.prepare(`
|
||||||
SELECT id, name, description, icon, enabled, config, sort_order
|
SELECT id, name, description, icon, enabled, sort_order
|
||||||
FROM photo_providers
|
FROM photo_providers
|
||||||
ORDER BY sort_order, id
|
ORDER BY sort_order, id
|
||||||
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>;
|
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }>;
|
||||||
const fields = db.prepare(`
|
const fields = db.prepare(`
|
||||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||||
FROM photo_provider_fields
|
FROM photo_provider_fields
|
||||||
@@ -502,7 +503,7 @@ export function listAddons() {
|
|||||||
type: 'photo_provider',
|
type: 'photo_provider',
|
||||||
icon: p.icon,
|
icon: p.icon,
|
||||||
enabled: !!p.enabled,
|
enabled: !!p.enabled,
|
||||||
config: JSON.parse(p.config || '{}'),
|
config: getPhotoProviderConfig(p.id),
|
||||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||||
key: f.field_key,
|
key: f.field_key,
|
||||||
label: f.label,
|
label: f.label,
|
||||||
@@ -521,7 +522,7 @@ export function listAddons() {
|
|||||||
|
|
||||||
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||||
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
|
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
||||||
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
||||||
|
|
||||||
if (addon) {
|
if (addon) {
|
||||||
@@ -529,11 +530,10 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
|
|||||||
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||||
} else {
|
} else {
|
||||||
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
if (data.enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||||
if (data.config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||||
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
|
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number } | undefined;
|
||||||
const updated = updatedAddon
|
const updated = updatedAddon
|
||||||
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
||||||
: updatedProvider
|
: updatedProvider
|
||||||
@@ -544,7 +544,7 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
|
|||||||
type: 'photo_provider',
|
type: 'photo_provider',
|
||||||
icon: updatedProvider.icon,
|
icon: updatedProvider.icon,
|
||||||
enabled: !!updatedProvider.enabled,
|
enabled: !!updatedProvider.enabled,
|
||||||
config: JSON.parse(updatedProvider.config || '{}'),
|
config: getPhotoProviderConfig(updatedProvider.id),
|
||||||
sort_order: updatedProvider.sort_order,
|
sort_order: updatedProvider.sort_order,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
186
server/src/services/memories/helpersService.ts
Normal file
186
server/src/services/memories/helpersService.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { canAccessTrip, db } from "../../db/database";
|
||||||
|
|
||||||
|
// helpers for handling return types
|
||||||
|
|
||||||
|
type ServiceError = { success: false; error: { message: string; status: number } };
|
||||||
|
export type ServiceResult<T> = { success: true; data: T } | ServiceError;
|
||||||
|
|
||||||
|
|
||||||
|
export function fail(error: string, status: number): ServiceError {
|
||||||
|
return { success: false, error: { message: error, status } };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function success<T>(data: T): ServiceResult<T> {
|
||||||
|
return { success: true, data: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function mapDbError(error: Error, fallbackMessage: string): ServiceError {
|
||||||
|
if (error && /unique|constraint/i.test(error.message)) {
|
||||||
|
return fail('Resource already exists', 409);
|
||||||
|
}
|
||||||
|
return fail(error.message, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function handleServiceResult<T>(res: Response, result: ServiceResult<T>): void {
|
||||||
|
if ('error' in result) {
|
||||||
|
res.status(result.error.status).json({ error: result.error.message });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
res.json(result.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------
|
||||||
|
// types used across memories services
|
||||||
|
export type Selection = {
|
||||||
|
provider: string;
|
||||||
|
asset_ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatusResult = {
|
||||||
|
connected: true;
|
||||||
|
user: { name: string }
|
||||||
|
} | {
|
||||||
|
connected: false;
|
||||||
|
error: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SyncAlbumResult = {
|
||||||
|
added: number;
|
||||||
|
total: number
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type AlbumsList = {
|
||||||
|
albums: Array<{ id: string; albumName: string; assetCount: number }>
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Asset = {
|
||||||
|
id: string;
|
||||||
|
takenAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssetsList = {
|
||||||
|
assets: Asset[],
|
||||||
|
total: number,
|
||||||
|
hasMore: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type AssetInfo = {
|
||||||
|
id: string;
|
||||||
|
takenAt: string | null;
|
||||||
|
city: string | null;
|
||||||
|
country: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
camera?: string | null;
|
||||||
|
lens?: string | null;
|
||||||
|
focalLength?: string | number | null;
|
||||||
|
aperture?: string | number | null;
|
||||||
|
shutter?: string | number | null;
|
||||||
|
iso?: string | number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
lng?: number | null;
|
||||||
|
orientation?: number | null;
|
||||||
|
description?: string | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//for loading routes to settings page, and validating which services user has connected
|
||||||
|
type PhotoProviderConfig = {
|
||||||
|
settings_get: string;
|
||||||
|
settings_put: string;
|
||||||
|
status_get: string;
|
||||||
|
test_post: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export function getPhotoProviderConfig(providerId: string): PhotoProviderConfig {
|
||||||
|
const prefix = `/integrations/memories/${providerId}`;
|
||||||
|
return {
|
||||||
|
settings_get: `${prefix}/settings`,
|
||||||
|
settings_put: `${prefix}/settings`,
|
||||||
|
status_get: `${prefix}/status`,
|
||||||
|
test_post: `${prefix}/test`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//-----------------------------------------------
|
||||||
|
//access check helper
|
||||||
|
|
||||||
|
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
|
||||||
|
if (requestingUserId === ownerUserId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const sharedAsset = db.prepare(`
|
||||||
|
SELECT 1
|
||||||
|
FROM trip_photos
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND asset_id = ?
|
||||||
|
AND provider = ?
|
||||||
|
AND trip_id = ?
|
||||||
|
AND shared = 1
|
||||||
|
LIMIT 1
|
||||||
|
`).get(ownerUserId, assetId, provider, tripId);
|
||||||
|
|
||||||
|
if (!sharedAsset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !!canAccessTrip(tripId, requestingUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------
|
||||||
|
//helpers for album link syncing
|
||||||
|
|
||||||
|
export function getAlbumIdFromLink(tripId: string, linkId: string, userId: number): ServiceResult<string> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) return fail('Trip not found or access denied', 404);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||||
|
.get(linkId, tripId, userId) as { album_id: string } | null;
|
||||||
|
|
||||||
|
return row ? success(row.album_id) : fail('Album link not found', 404);
|
||||||
|
} catch {
|
||||||
|
return fail('Failed to retrieve album link', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSyncTimeForAlbumLink(linkId: string): void {
|
||||||
|
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pipeAsset(url: string, response: Response): Promise<void> {
|
||||||
|
try{
|
||||||
|
const resp = await fetch(url);
|
||||||
|
|
||||||
|
response.status(resp.status);
|
||||||
|
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
|
||||||
|
if (resp.headers.get('cache-control')) response.set('Cache-Control', resp.headers.get('cache-control') as string);
|
||||||
|
if (resp.headers.get('content-length')) response.set('Content-Length', resp.headers.get('content-length') as string);
|
||||||
|
if (resp.headers.get('content-disposition')) response.set('Content-Disposition', resp.headers.get('content-disposition') as string);
|
||||||
|
|
||||||
|
if (!resp.body) {
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
pipeline(Readable.fromWeb(resp.body), response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
response.status(500).json({ error: 'Failed to fetch asset' });
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { db } from '../db/database';
|
import { db } from '../../db/database';
|
||||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||||
import { writeAudit } from './auditLog';
|
import { writeAudit } from '../auditLog';
|
||||||
|
import { addTripPhotos} from './unifiedService';
|
||||||
|
import { getAlbumIdFromLink, updateSyncTimeForAlbumLink, Selection } from './helpersService';
|
||||||
|
|
||||||
// ── Credentials ────────────────────────────────────────────────────────────
|
// ── Credentials ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -313,15 +315,14 @@ export async function syncAlbumAssets(
|
|||||||
linkId: string,
|
linkId: string,
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
|
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
|
||||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?')
|
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||||
.get(linkId, tripId, userId, 'immich') as any;
|
if (!response.success) return { error: 'Album link not found', status: 404 };
|
||||||
if (!link) return { error: 'Album link not found', status: 404 };
|
|
||||||
|
|
||||||
const creds = getImmichCredentials(userId);
|
const creds = getImmichCredentials(userId);
|
||||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, {
|
const resp = await fetch(`${creds.immich_url}/api/albums/${response.data}`, {
|
||||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
@@ -329,16 +330,17 @@ export async function syncAlbumAssets(
|
|||||||
const albumData = await resp.json() as { assets?: any[] };
|
const albumData = await resp.json() as { assets?: any[] };
|
||||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||||
|
|
||||||
const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)");
|
const selection: Selection = {
|
||||||
let added = 0;
|
provider: 'immich',
|
||||||
for (const asset of assets) {
|
asset_ids: assets.map((a: any) => a.id),
|
||||||
const r = insert.run(tripId, userId, asset.id);
|
};
|
||||||
if (r.changes > 0) added++;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
const result = await addTripPhotos(tripId, userId, true, [selection]);
|
||||||
|
if ('error' in result) return { error: result.error.message, status: result.error.status };
|
||||||
|
|
||||||
return { success: true, added, total: assets.length };
|
updateSyncTimeForAlbumLink(linkId);
|
||||||
|
|
||||||
|
return { success: true, added: result.data.added, total: assets.length };
|
||||||
} catch {
|
} catch {
|
||||||
return { error: 'Could not reach Immich', status: 502 };
|
return { error: 'Could not reach Immich', status: 502 };
|
||||||
}
|
}
|
||||||
496
server/src/services/memories/synologyService.ts
Normal file
496
server/src/services/memories/synologyService.ts
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { db } from '../../db/database';
|
||||||
|
import { decrypt_api_key, maybe_encrypt_api_key } from '../apiKeyCrypto';
|
||||||
|
import { checkSsrf } from '../../utils/ssrfGuard';
|
||||||
|
import { addTripPhotos } from './unifiedService';
|
||||||
|
import {
|
||||||
|
getAlbumIdFromLink,
|
||||||
|
updateSyncTimeForAlbumLink,
|
||||||
|
Selection,
|
||||||
|
ServiceResult,
|
||||||
|
fail,
|
||||||
|
success,
|
||||||
|
handleServiceResult,
|
||||||
|
pipeAsset,
|
||||||
|
AlbumsList,
|
||||||
|
AssetsList,
|
||||||
|
StatusResult,
|
||||||
|
SyncAlbumResult,
|
||||||
|
AssetInfo
|
||||||
|
} from './helpersService';
|
||||||
|
|
||||||
|
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
||||||
|
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
|
||||||
|
|
||||||
|
interface SynologyUserRecord {
|
||||||
|
synology_url?: string | null;
|
||||||
|
synology_username?: string | null;
|
||||||
|
synology_password?: string | null;
|
||||||
|
synology_sid?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SynologyCredentials {
|
||||||
|
synology_url: string;
|
||||||
|
synology_username: string;
|
||||||
|
synology_password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SynologySettings {
|
||||||
|
synology_url: string;
|
||||||
|
synology_username: string;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiCallParams {
|
||||||
|
api: string;
|
||||||
|
method: string;
|
||||||
|
version?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SynologyApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: { code: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface SynologyPhotoItem {
|
||||||
|
id?: string | number;
|
||||||
|
filename?: string;
|
||||||
|
filesize?: number;
|
||||||
|
time?: number;
|
||||||
|
item_count?: number;
|
||||||
|
name?: string;
|
||||||
|
additional?: {
|
||||||
|
thumbnail?: { cache_key?: string };
|
||||||
|
address?: { city?: string; country?: string; state?: string };
|
||||||
|
resolution?: { width?: number; height?: number };
|
||||||
|
exif?: {
|
||||||
|
camera?: string;
|
||||||
|
lens?: string;
|
||||||
|
focal_length?: string | number;
|
||||||
|
aperture?: string | number;
|
||||||
|
exposure_time?: string | number;
|
||||||
|
iso?: string | number;
|
||||||
|
};
|
||||||
|
gps?: { latitude?: number; longitude?: number };
|
||||||
|
orientation?: number;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
|
||||||
|
try {
|
||||||
|
|
||||||
|
if (!columns) return null;
|
||||||
|
|
||||||
|
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return fail('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: SynologyUserRecord = {};
|
||||||
|
for (const column of columns) {
|
||||||
|
filtered[column] = row[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filtered) {
|
||||||
|
return fail('Failed to read Synology user data', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success(filtered);
|
||||||
|
} catch {
|
||||||
|
return fail('Failed to read Synology user data', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
|
||||||
|
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
||||||
|
if (!user.success) return user as ServiceResult<SynologyCredentials>;
|
||||||
|
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
|
||||||
|
return success({
|
||||||
|
synology_url: user.data.synology_url,
|
||||||
|
synology_username: user.data.synology_username,
|
||||||
|
synology_password: decrypt_api_key(user.data.synology_password) as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function _buildSynologyEndpoint(url: string): string {
|
||||||
|
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
|
||||||
|
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
|
||||||
|
const endpoint = _buildSynologyEndpoint(url);
|
||||||
|
const resp = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
return fail('Synology API request failed with status ' + resp.status, resp.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await resp.json() as SynologyApiResponse<T>;
|
||||||
|
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
api: 'SYNO.API.Auth',
|
||||||
|
method: 'login',
|
||||||
|
version: '3',
|
||||||
|
account: username,
|
||||||
|
passwd: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
|
||||||
|
if (!result.success) {
|
||||||
|
return result as ServiceResult<string>;
|
||||||
|
}
|
||||||
|
if (!result.data.sid) {
|
||||||
|
return fail('Failed to get session ID from Synology', 500);
|
||||||
|
}
|
||||||
|
return success(result.data.sid);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
|
||||||
|
const creds = _getSynologyCredentials(userId);
|
||||||
|
if (!creds.success) {
|
||||||
|
return creds as ServiceResult<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await _getSynologySession(userId);
|
||||||
|
if (!session.success || !session.data) {
|
||||||
|
return session as ServiceResult<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
|
||||||
|
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
|
||||||
|
if ('error' in result && result.error.status === 119) {
|
||||||
|
_clearSynologySID(userId);
|
||||||
|
const retrySession = await _getSynologySession(userId);
|
||||||
|
if (!retrySession.success || !retrySession.data) {
|
||||||
|
return session as ServiceResult<T>;
|
||||||
|
}
|
||||||
|
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo {
|
||||||
|
const address = item.additional?.address || {};
|
||||||
|
const exif = item.additional?.exif || {};
|
||||||
|
const gps = item.additional?.gps || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(item.additional?.thumbnail?.cache_key || ''),
|
||||||
|
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
|
||||||
|
city: address.city || null,
|
||||||
|
country: address.country || null,
|
||||||
|
state: address.state || null,
|
||||||
|
camera: exif.camera || null,
|
||||||
|
lens: exif.lens || null,
|
||||||
|
focalLength: exif.focal_length || null,
|
||||||
|
aperture: exif.aperture || null,
|
||||||
|
shutter: exif.exposure_time || null,
|
||||||
|
iso: exif.iso || null,
|
||||||
|
lat: gps.latitude || null,
|
||||||
|
lng: gps.longitude || null,
|
||||||
|
orientation: item.additional?.orientation || null,
|
||||||
|
description: item.additional?.description || null,
|
||||||
|
width: item.additional?.resolution?.width || null,
|
||||||
|
height: item.additional?.resolution?.height || null,
|
||||||
|
fileSize: item.filesize || null,
|
||||||
|
fileName: item.filename || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cacheSynologySID(userId: number, sid: string): void {
|
||||||
|
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearSynologySID(userId: number): void {
|
||||||
|
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
|
||||||
|
const id = rawId.split('_')[0];
|
||||||
|
return { id, cacheKey: rawId, assetId: rawId };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
|
||||||
|
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
|
||||||
|
if (cachedSid.success && cachedSid.data?.synology_sid) {
|
||||||
|
return success(cachedSid.data.synology_sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = _getSynologyCredentials(userId);
|
||||||
|
if (!creds.success) {
|
||||||
|
return creds as ServiceResult<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
|
||||||
|
|
||||||
|
if (!resp.success) {
|
||||||
|
return resp as ServiceResult<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cacheSynologySID(userId, resp.data);
|
||||||
|
return success(resp.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
|
||||||
|
const creds = _getSynologyCredentials(userId);
|
||||||
|
if (!creds.success) return creds as ServiceResult<SynologySettings>;
|
||||||
|
const session = await _getSynologySession(userId);
|
||||||
|
return success({
|
||||||
|
synology_url: creds.data.synology_url || '',
|
||||||
|
synology_username: creds.data.synology_username || '',
|
||||||
|
connected: session.success,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
|
||||||
|
|
||||||
|
const ssrf = await checkSsrf(synologyUrl);
|
||||||
|
if (!ssrf.allowed) {
|
||||||
|
return fail(ssrf.error, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = _readSynologyUser(userId, ['synology_password'])
|
||||||
|
if (!result.success) return result as ServiceResult<string>;
|
||||||
|
const existingEncryptedPassword = result.data?.synology_password || null;
|
||||||
|
|
||||||
|
if (!synologyPassword && !existingEncryptedPassword) {
|
||||||
|
return fail('No stored password found. Please provide a password to save settings.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
||||||
|
synologyUrl,
|
||||||
|
synologyUsername,
|
||||||
|
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return fail('Failed to update Synology settings', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearSynologySID(userId);
|
||||||
|
return success("settings updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
|
||||||
|
const sid = await _getSynologySession(userId);
|
||||||
|
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
|
||||||
|
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
|
||||||
|
try {
|
||||||
|
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
|
||||||
|
return success({ connected: true, user: { name: user?.synology_username || 'unknown user' } });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
return success({ connected: true, user: { name: 'unknown user' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
|
||||||
|
|
||||||
|
const ssrf = await checkSsrf(synologyUrl);
|
||||||
|
if (!ssrf.allowed) {
|
||||||
|
return fail(ssrf.error, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
|
||||||
|
if ('error' in resp) {
|
||||||
|
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
|
||||||
|
}
|
||||||
|
return success({ connected: true, user: { name: synologyUsername } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSynologyAlbums(userId: number): Promise<ServiceResult<AlbumsList>> {
|
||||||
|
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||||
|
api: 'SYNO.Foto.Browse.Album',
|
||||||
|
method: 'list',
|
||||||
|
version: 4,
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
if (!result.success) return result as ServiceResult<AlbumsList>;
|
||||||
|
|
||||||
|
const albums = (result.data.list || []).map((album: any) => ({
|
||||||
|
id: String(album.id),
|
||||||
|
albumName: album.name || '',
|
||||||
|
assetCount: album.item_count || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return success({ albums });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<ServiceResult<SyncAlbumResult>> {
|
||||||
|
const response = getAlbumIdFromLink(tripId, linkId, userId);
|
||||||
|
if (!response.success) return response as ServiceResult<SyncAlbumResult>;
|
||||||
|
|
||||||
|
const allItems: SynologyPhotoItem[] = [];
|
||||||
|
const pageSize = 1000;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
||||||
|
api: 'SYNO.Foto.Browse.Item',
|
||||||
|
method: 'list',
|
||||||
|
version: 1,
|
||||||
|
album_id: Number(response.data),
|
||||||
|
offset,
|
||||||
|
limit: pageSize,
|
||||||
|
additional: ['thumbnail'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||||
|
|
||||||
|
const items = result.data.list || [];
|
||||||
|
allItems.push(...items);
|
||||||
|
if (items.length < pageSize) break;
|
||||||
|
offset += pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection: Selection = {
|
||||||
|
provider: SYNOLOGY_PROVIDER,
|
||||||
|
asset_ids: allItems.map(item => String(item.additional?.thumbnail?.cache_key || '')).filter(id => id),
|
||||||
|
};
|
||||||
|
|
||||||
|
updateSyncTimeForAlbumLink(linkId);
|
||||||
|
|
||||||
|
const result = await addTripPhotos(tripId, userId, true, [selection]);
|
||||||
|
if (!result.success) return result as ServiceResult<SyncAlbumResult>;
|
||||||
|
|
||||||
|
return success({ added: result.data.added, total: allItems.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<ServiceResult<AssetsList>> {
|
||||||
|
const params: ApiCallParams = {
|
||||||
|
api: 'SYNO.Foto.Search.Search',
|
||||||
|
method: 'list_item',
|
||||||
|
version: 1,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
keyword: '.',
|
||||||
|
additional: ['thumbnail', 'address'],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (from || to) {
|
||||||
|
if (from) {
|
||||||
|
params.start_time = Math.floor(new Date(from).getTime() / 1000);
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
|
||||||
|
if (!result.success) return result as ServiceResult<{ assets: AssetInfo[]; total: number; hasMore: boolean }>;
|
||||||
|
|
||||||
|
const allItems = result.data.list || [];
|
||||||
|
const total = allItems.length;
|
||||||
|
const assets = allItems.map(item => _normalizeSynologyPhotoInfo(item));
|
||||||
|
|
||||||
|
return success({
|
||||||
|
assets,
|
||||||
|
total,
|
||||||
|
hasMore: total === limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<ServiceResult<AssetInfo>> {
|
||||||
|
const parsedId = _splitPackedSynologyId(photoId);
|
||||||
|
const result = await _requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId, {
|
||||||
|
api: 'SYNO.Foto.Browse.Item',
|
||||||
|
method: 'get',
|
||||||
|
version: 5,
|
||||||
|
id: `[${Number(parsedId.id) + 1}]`, //for some reason synology wants id moved by one to get image info
|
||||||
|
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) return result as ServiceResult<AssetInfo>;
|
||||||
|
|
||||||
|
const metadata = result.data.list?.[0];
|
||||||
|
if (!metadata) return fail('Photo not found', 404);
|
||||||
|
|
||||||
|
const normalized = _normalizeSynologyPhotoInfo(metadata);
|
||||||
|
normalized.id = photoId;
|
||||||
|
return success(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamSynologyAsset(
|
||||||
|
response: Response,
|
||||||
|
userId: number,
|
||||||
|
targetUserId: number,
|
||||||
|
photoId: string,
|
||||||
|
kind: 'thumbnail' | 'original',
|
||||||
|
size?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const parsedId = _splitPackedSynologyId(photoId);
|
||||||
|
|
||||||
|
const synology_credentials = _getSynologyCredentials(targetUserId);
|
||||||
|
if (!synology_credentials.success) {
|
||||||
|
handleServiceResult(response, synology_credentials);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sid = await _getSynologySession(targetUserId);
|
||||||
|
if (!sid.success) {
|
||||||
|
handleServiceResult(response, sid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sid.data) {
|
||||||
|
handleServiceResult(response, fail('Failed to retrieve session ID', 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = kind === 'thumbnail'
|
||||||
|
? new URLSearchParams({
|
||||||
|
api: 'SYNO.Foto.Thumbnail',
|
||||||
|
method: 'get',
|
||||||
|
version: '2',
|
||||||
|
mode: 'download',
|
||||||
|
id: parsedId.id,
|
||||||
|
type: 'unit',
|
||||||
|
size: size,
|
||||||
|
cache_key: parsedId.cacheKey,
|
||||||
|
_sid: sid.data,
|
||||||
|
})
|
||||||
|
: new URLSearchParams({
|
||||||
|
api: 'SYNO.Foto.Download',
|
||||||
|
method: 'download',
|
||||||
|
version: '2',
|
||||||
|
cache_key: parsedId.cacheKey,
|
||||||
|
unit_id: `[${parsedId.id}]`,
|
||||||
|
_sid: sid.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${_buildSynologyEndpoint(synology_credentials.data.synology_url)}?${params.toString()}`;
|
||||||
|
|
||||||
|
await pipeAsset(url, response)
|
||||||
|
}
|
||||||
|
|
||||||
249
server/src/services/memories/unifiedService.ts
Normal file
249
server/src/services/memories/unifiedService.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { db, canAccessTrip } from '../../db/database';
|
||||||
|
import { notifyTripMembers } from '../notifications';
|
||||||
|
import { broadcast } from '../../websocket';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ServiceResult,
|
||||||
|
fail,
|
||||||
|
success,
|
||||||
|
mapDbError,
|
||||||
|
Selection,
|
||||||
|
} from './helpersService';
|
||||||
|
|
||||||
|
|
||||||
|
export function listTripPhotos(tripId: string, userId: number): ServiceResult<any[]> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const photos = db.prepare(`
|
||||||
|
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
|
||||||
|
u.username, u.avatar
|
||||||
|
FROM trip_photos tp
|
||||||
|
JOIN users u ON tp.user_id = u.id
|
||||||
|
WHERE tp.trip_id = ?
|
||||||
|
AND (tp.user_id = ? OR tp.shared = 1)
|
||||||
|
ORDER BY tp.added_at ASC
|
||||||
|
`).all(tripId, userId) as any[];
|
||||||
|
|
||||||
|
return success(photos);
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to list trip photos');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listTripAlbumLinks(tripId: string, userId: number): ServiceResult<any[]> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const links = db.prepare(`
|
||||||
|
SELECT tal.id,
|
||||||
|
tal.trip_id,
|
||||||
|
tal.user_id,
|
||||||
|
tal.provider,
|
||||||
|
tal.album_id,
|
||||||
|
tal.album_name,
|
||||||
|
tal.sync_enabled,
|
||||||
|
tal.last_synced_at,
|
||||||
|
tal.created_at,
|
||||||
|
u.username
|
||||||
|
FROM trip_album_links tal
|
||||||
|
JOIN users u ON tal.user_id = u.id
|
||||||
|
WHERE tal.trip_id = ?
|
||||||
|
ORDER BY tal.created_at ASC
|
||||||
|
`).all(tripId);
|
||||||
|
|
||||||
|
return success(links);
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to list trip album links');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//-----------------------------------------------
|
||||||
|
// managing photos in trip
|
||||||
|
|
||||||
|
function _addTripPhoto(tripId: string, userId: number, provider: string, assetId: string, shared: boolean): boolean {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(tripId, userId, assetId, provider, shared ? 1 : 0);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTripPhotos(
|
||||||
|
tripId: string,
|
||||||
|
userId: number,
|
||||||
|
shared: boolean,
|
||||||
|
selections: Selection[],
|
||||||
|
sid?: string,
|
||||||
|
): Promise<ServiceResult<{ added: number; shared: boolean }>> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selections.length === 0) {
|
||||||
|
return fail('No photos selected', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let added = 0;
|
||||||
|
for (const selection of selections) {
|
||||||
|
for (const raw of selection.asset_ids) {
|
||||||
|
const assetId = String(raw || '').trim();
|
||||||
|
if (!assetId) continue;
|
||||||
|
if (_addTripPhoto(tripId, userId, selection.provider, assetId, shared)) {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _notifySharedTripPhotos(tripId, userId, added);
|
||||||
|
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||||
|
return success({ added, shared });
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to add trip photos');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function setTripPhotoSharing(
|
||||||
|
tripId: string,
|
||||||
|
userId: number,
|
||||||
|
provider: string,
|
||||||
|
assetId: string,
|
||||||
|
shared: boolean,
|
||||||
|
sid?: string,
|
||||||
|
): Promise<ServiceResult<true>> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE trip_photos
|
||||||
|
SET shared = ?
|
||||||
|
WHERE trip_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND asset_id = ?
|
||||||
|
AND provider = ?
|
||||||
|
`).run(shared ? 1 : 0, tripId, userId, assetId, provider);
|
||||||
|
|
||||||
|
await _notifySharedTripPhotos(tripId, userId, 1);
|
||||||
|
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||||
|
return success(true);
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to update photo sharing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTripPhoto(
|
||||||
|
tripId: string,
|
||||||
|
userId: number,
|
||||||
|
provider: string,
|
||||||
|
assetId: string,
|
||||||
|
sid?: string,
|
||||||
|
): ServiceResult<true> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM trip_photos
|
||||||
|
WHERE trip_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND asset_id = ?
|
||||||
|
AND provider = ?
|
||||||
|
`).run(tripId, userId, assetId, provider);
|
||||||
|
|
||||||
|
broadcast(tripId, 'memories:updated', { userId }, sid);
|
||||||
|
|
||||||
|
return success(true);
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to remove trip photo');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------
|
||||||
|
// managing album links in trip
|
||||||
|
|
||||||
|
export function createTripAlbumLink(tripId: string, userId: number, providerRaw: unknown, albumIdRaw: unknown, albumNameRaw: unknown): ServiceResult<true> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = String(providerRaw || '').toLowerCase();
|
||||||
|
const albumId = String(albumIdRaw || '').trim();
|
||||||
|
const albumName = String(albumNameRaw || '').trim();
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
return fail('provider is required', 400);
|
||||||
|
}
|
||||||
|
if (!albumId) {
|
||||||
|
return fail('album_id required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).run(tripId, userId, provider, albumId, albumName);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return fail('Album already linked', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success(true);
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to link album');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAlbumLink(tripId: string, linkId: string, userId: number): ServiceResult<true> {
|
||||||
|
const access = canAccessTrip(tripId, userId);
|
||||||
|
if (!access) {
|
||||||
|
return fail('Trip not found or access denied', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||||
|
.run(linkId, tripId, userId);
|
||||||
|
|
||||||
|
return success(true);
|
||||||
|
} catch (error) {
|
||||||
|
return mapDbError(error, 'Failed to remove album link');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//-----------------------------------------------
|
||||||
|
// notifications helper
|
||||||
|
|
||||||
|
async function _notifySharedTripPhotos(
|
||||||
|
tripId: string,
|
||||||
|
actorUserId: number,
|
||||||
|
added: number,
|
||||||
|
): Promise<ServiceResult<void>> {
|
||||||
|
if (added <= 0) return fail('No photos shared, skipping notifications', 200);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const actorRow = db.prepare('SELECT username FROM users WHERE id = ?').get(actorUserId) as { username: string | null };
|
||||||
|
|
||||||
|
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||||
|
await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
|
||||||
|
trip: tripInfo?.title || 'Untitled',
|
||||||
|
actor: actorRow?.username || 'Unknown',
|
||||||
|
count: String(added),
|
||||||
|
});
|
||||||
|
return success(undefined);
|
||||||
|
} catch {
|
||||||
|
return fail('Failed to send notifications', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
import { db, canAccessTrip } from '../db/database';
|
|
||||||
import { notifyTripMembers } from './notifications';
|
|
||||||
|
|
||||||
type ServiceError = { error: string; status: number };
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify that requestingUserId can access a shared photo belonging to ownerUserId.
|
|
||||||
* The asset must be shared (shared=1) and the requesting user must be a member of
|
|
||||||
* the same trip that contains the photo.
|
|
||||||
*/
|
|
||||||
export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean {
|
|
||||||
if (requestingUserId === ownerUserId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const sharedAsset = db.prepare(`
|
|
||||||
SELECT 1
|
|
||||||
FROM trip_photos
|
|
||||||
WHERE user_id = ?
|
|
||||||
AND asset_id = ?
|
|
||||||
AND provider = ?
|
|
||||||
AND trip_id = ?
|
|
||||||
AND shared = 1
|
|
||||||
LIMIT 1
|
|
||||||
`).get(ownerUserId, assetId, provider, tripId);
|
|
||||||
|
|
||||||
if (!sharedAsset) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !!canAccessTrip(String(tripId), requestingUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function accessDeniedIfMissing(tripId: string, userId: number): ServiceError | null {
|
|
||||||
if (!canAccessTrip(tripId, userId)) {
|
|
||||||
return { error: 'Trip not found', status: 404 };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Selection = {
|
|
||||||
provider: string;
|
|
||||||
asset_ids: unknown[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeSelections(selectionsRaw: unknown, providerRaw: unknown, assetIdsRaw: unknown): Selection[] {
|
|
||||||
const selectionsFromBody = Array.isArray(selectionsRaw) ? selectionsRaw : null;
|
|
||||||
const provider = String(providerRaw || '').toLowerCase();
|
|
||||||
|
|
||||||
if (selectionsFromBody && selectionsFromBody.length > 0) {
|
|
||||||
return selectionsFromBody
|
|
||||||
.map((selection: any) => ({
|
|
||||||
provider: String(selection?.provider || '').toLowerCase(),
|
|
||||||
asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [],
|
|
||||||
}))
|
|
||||||
.filter((selection: Selection) => selection.provider && selection.asset_ids.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0) {
|
|
||||||
return [{ provider, asset_ids: assetIdsRaw }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listTripPhotos(tripId: string, userId: number): { photos: any[] } | ServiceError {
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const photos = db.prepare(`
|
|
||||||
SELECT tp.asset_id, tp.provider, tp.user_id, tp.shared, tp.added_at,
|
|
||||||
u.username, u.avatar
|
|
||||||
FROM trip_photos tp
|
|
||||||
JOIN users u ON tp.user_id = u.id
|
|
||||||
WHERE tp.trip_id = ?
|
|
||||||
AND (tp.user_id = ? OR tp.shared = 1)
|
|
||||||
ORDER BY tp.added_at ASC
|
|
||||||
`).all(tripId, userId) as any[];
|
|
||||||
|
|
||||||
return { photos };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listTripAlbumLinks(tripId: string, userId: number): { links: any[] } | ServiceError {
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const links = db.prepare(`
|
|
||||||
SELECT tal.id,
|
|
||||||
tal.trip_id,
|
|
||||||
tal.user_id,
|
|
||||||
tal.provider,
|
|
||||||
tal.album_id,
|
|
||||||
tal.album_name,
|
|
||||||
tal.sync_enabled,
|
|
||||||
tal.last_synced_at,
|
|
||||||
tal.created_at,
|
|
||||||
u.username
|
|
||||||
FROM trip_album_links tal
|
|
||||||
JOIN users u ON tal.user_id = u.id
|
|
||||||
WHERE tal.trip_id = ?
|
|
||||||
ORDER BY tal.created_at ASC
|
|
||||||
`).all(tripId);
|
|
||||||
|
|
||||||
return { links };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTripAlbumLink(
|
|
||||||
tripId: string,
|
|
||||||
userId: number,
|
|
||||||
providerRaw: unknown,
|
|
||||||
albumIdRaw: unknown,
|
|
||||||
albumNameRaw: unknown,
|
|
||||||
): { success: true } | ServiceError {
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const provider = String(providerRaw || '').toLowerCase();
|
|
||||||
const albumId = String(albumIdRaw || '').trim();
|
|
||||||
const albumName = String(albumNameRaw || '').trim();
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
return { error: 'provider is required', status: 400 };
|
|
||||||
}
|
|
||||||
if (!albumId) {
|
|
||||||
return { error: 'album_id required', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
|
||||||
).run(tripId, userId, provider, albumId, albumName);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { error: 'Album already linked', status: 400 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeAlbumLink(tripId: string, linkId: string, userId: number): { success: true } | ServiceError {
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
|
||||||
.run(linkId, tripId, userId);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addTripPhotos(
|
|
||||||
tripId: string,
|
|
||||||
userId: number,
|
|
||||||
sharedRaw: unknown,
|
|
||||||
selectionsRaw: unknown,
|
|
||||||
providerRaw: unknown,
|
|
||||||
assetIdsRaw: unknown,
|
|
||||||
): { success: true; added: number; shared: boolean } | ServiceError {
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const shared = sharedRaw === undefined ? true : !!sharedRaw;
|
|
||||||
const selections = normalizeSelections(selectionsRaw, providerRaw, assetIdsRaw);
|
|
||||||
if (selections.length === 0) {
|
|
||||||
return { error: 'selections required', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const insert = db.prepare(
|
|
||||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)'
|
|
||||||
);
|
|
||||||
|
|
||||||
let added = 0;
|
|
||||||
for (const selection of selections) {
|
|
||||||
for (const raw of selection.asset_ids) {
|
|
||||||
const assetId = String(raw || '').trim();
|
|
||||||
if (!assetId) continue;
|
|
||||||
const result = insert.run(tripId, userId, assetId, selection.provider, shared ? 1 : 0);
|
|
||||||
if (result.changes > 0) added++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, added, shared };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeTripPhoto(
|
|
||||||
tripId: string,
|
|
||||||
userId: number,
|
|
||||||
providerRaw: unknown,
|
|
||||||
assetIdRaw: unknown,
|
|
||||||
): { success: true } | ServiceError {
|
|
||||||
const assetId = String(assetIdRaw || '');
|
|
||||||
const provider = String(providerRaw || '').toLowerCase();
|
|
||||||
|
|
||||||
if (!assetId) {
|
|
||||||
return { error: 'asset_id is required', status: 400 };
|
|
||||||
}
|
|
||||||
if (!provider) {
|
|
||||||
return { error: 'provider is required', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
DELETE FROM trip_photos
|
|
||||||
WHERE trip_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
AND asset_id = ?
|
|
||||||
AND provider = ?
|
|
||||||
`).run(tripId, userId, assetId, provider);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setTripPhotoSharing(
|
|
||||||
tripId: string,
|
|
||||||
userId: number,
|
|
||||||
providerRaw: unknown,
|
|
||||||
assetIdRaw: unknown,
|
|
||||||
sharedRaw: unknown,
|
|
||||||
): { success: true } | ServiceError {
|
|
||||||
const assetId = String(assetIdRaw || '');
|
|
||||||
const provider = String(providerRaw || '').toLowerCase();
|
|
||||||
|
|
||||||
if (!assetId) {
|
|
||||||
return { error: 'asset_id is required', status: 400 };
|
|
||||||
}
|
|
||||||
if (!provider) {
|
|
||||||
return { error: 'provider is required', status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const denied = accessDeniedIfMissing(tripId, userId);
|
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE trip_photos
|
|
||||||
SET shared = ?
|
|
||||||
WHERE trip_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
AND asset_id = ?
|
|
||||||
AND provider = ?
|
|
||||||
`).run(sharedRaw ? 1 : 0, tripId, userId, assetId, provider);
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notifySharedTripPhotos(
|
|
||||||
tripId: string,
|
|
||||||
actorUserId: number,
|
|
||||||
actorName: string,
|
|
||||||
added: number,
|
|
||||||
): Promise<void> {
|
|
||||||
if (added <= 0) return;
|
|
||||||
|
|
||||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
|
||||||
await notifyTripMembers(Number(tripId), actorUserId, 'photos_shared', {
|
|
||||||
trip: tripInfo?.title || 'Untitled',
|
|
||||||
actor: actorName,
|
|
||||||
count: String(added),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
import { Readable } from 'node:stream';
|
|
||||||
import { pipeline } from 'node:stream/promises';
|
|
||||||
import { Request, Response as ExpressResponse } from 'express';
|
|
||||||
import { db } from '../db/database';
|
|
||||||
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
|
|
||||||
import { checkSsrf } from '../utils/ssrfGuard';
|
|
||||||
|
|
||||||
const SYNOLOGY_API_TIMEOUT_MS = 30000;
|
|
||||||
const SYNOLOGY_PROVIDER = 'synologyphotos';
|
|
||||||
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
|
|
||||||
const SYNOLOGY_DEFAULT_THUMBNAIL_SIZE = 'sm';
|
|
||||||
|
|
||||||
interface SynologyCredentials {
|
|
||||||
synology_url: string;
|
|
||||||
synology_username: string;
|
|
||||||
synology_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SynologySession {
|
|
||||||
success: boolean;
|
|
||||||
sid?: string;
|
|
||||||
error?: { code: number; message?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiCallParams {
|
|
||||||
api: string;
|
|
||||||
method: string;
|
|
||||||
version?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SynologyApiResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
data?: T;
|
|
||||||
error?: { code: number; message?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SynologyServiceError extends Error {
|
|
||||||
status: number;
|
|
||||||
|
|
||||||
constructor(status: number, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SynologySettings {
|
|
||||||
synology_url: string;
|
|
||||||
synology_username: string;
|
|
||||||
connected: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SynologyConnectionResult {
|
|
||||||
connected: boolean;
|
|
||||||
user?: { username: string };
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SynologyAlbumLinkInput {
|
|
||||||
album_id?: string | number;
|
|
||||||
album_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SynologySearchInput {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SynologyProxyResult {
|
|
||||||
status: number;
|
|
||||||
headers: Record<string, string | null>;
|
|
||||||
body: ReadableStream<Uint8Array> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SynologyPhotoInfo {
|
|
||||||
id: string;
|
|
||||||
takenAt: string | null;
|
|
||||||
city: string | null;
|
|
||||||
country: string | null;
|
|
||||||
state?: string | null;
|
|
||||||
camera?: string | null;
|
|
||||||
lens?: string | null;
|
|
||||||
focalLength?: string | number | null;
|
|
||||||
aperture?: string | number | null;
|
|
||||||
shutter?: string | number | null;
|
|
||||||
iso?: string | number | null;
|
|
||||||
lat?: number | null;
|
|
||||||
lng?: number | null;
|
|
||||||
orientation?: number | null;
|
|
||||||
description?: string | null;
|
|
||||||
filename?: string | null;
|
|
||||||
filesize?: number | null;
|
|
||||||
width?: number | null;
|
|
||||||
height?: number | null;
|
|
||||||
fileSize?: number | null;
|
|
||||||
fileName?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SynologyPhotoItem {
|
|
||||||
id?: string | number;
|
|
||||||
filename?: string;
|
|
||||||
filesize?: number;
|
|
||||||
time?: number;
|
|
||||||
item_count?: number;
|
|
||||||
name?: string;
|
|
||||||
additional?: {
|
|
||||||
thumbnail?: { cache_key?: string };
|
|
||||||
address?: { city?: string; country?: string; state?: string };
|
|
||||||
resolution?: { width?: number; height?: number };
|
|
||||||
exif?: {
|
|
||||||
camera?: string;
|
|
||||||
lens?: string;
|
|
||||||
focal_length?: string | number;
|
|
||||||
aperture?: string | number;
|
|
||||||
exposure_time?: string | number;
|
|
||||||
iso?: string | number;
|
|
||||||
};
|
|
||||||
gps?: { latitude?: number; longitude?: number };
|
|
||||||
orientation?: number;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type SynologyUserRecord = {
|
|
||||||
synology_url?: string | null;
|
|
||||||
synology_username?: string | null;
|
|
||||||
synology_password?: string | null;
|
|
||||||
synology_sid?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function readSynologyUser(userId: number, columns: string[]): SynologyUserRecord | null {
|
|
||||||
try {
|
|
||||||
|
|
||||||
if (!columns) return null;
|
|
||||||
|
|
||||||
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
const filtered: SynologyUserRecord = {};
|
|
||||||
for (const column of columns) {
|
|
||||||
filtered[column] = row[column];
|
|
||||||
}
|
|
||||||
|
|
||||||
return filtered || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSynologyCredentials(userId: number): SynologyCredentials | null {
|
|
||||||
const user = readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
|
|
||||||
if (!user?.synology_url || !user.synology_username || !user.synology_password) return null;
|
|
||||||
return {
|
|
||||||
synology_url: user.synology_url,
|
|
||||||
synology_username: user.synology_username,
|
|
||||||
synology_password: decrypt_api_key(user.synology_password) as string,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function buildSynologyEndpoint(url: string): string {
|
|
||||||
const normalized = url.replace(/\/$/, '').match(/^https?:\/\//) ? url.replace(/\/$/, '') : `https://${url.replace(/\/$/, '')}`;
|
|
||||||
return `${normalized}${SYNOLOGY_ENDPOINT_PATH}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
|
|
||||||
const body = new URLSearchParams();
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
if (value === undefined || value === null) continue;
|
|
||||||
body.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<SynologyApiResponse<T>> {
|
|
||||||
const endpoint = buildSynologyEndpoint(url);
|
|
||||||
const resp = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const text = await resp.text();
|
|
||||||
return { success: false, error: { code: resp.status, message: text } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp.json() as Promise<SynologyApiResponse<T>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginToSynology(url: string, username: string, password: string): Promise<SynologyApiResponse<{ sid?: string }>> {
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
api: 'SYNO.API.Auth',
|
|
||||||
method: 'login',
|
|
||||||
version: '3',
|
|
||||||
account: username,
|
|
||||||
passwd: password,
|
|
||||||
});
|
|
||||||
|
|
||||||
return fetchSynologyJson<{ sid?: string }>(url, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<SynologyApiResponse<T>> {
|
|
||||||
const creds = getSynologyCredentials(userId);
|
|
||||||
if (!creds) {
|
|
||||||
return { success: false, error: { code: 400, message: 'Synology not configured' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await getSynologySession(userId);
|
|
||||||
if (!session.success || !session.sid) {
|
|
||||||
return { success: false, error: session.error || { code: 400, message: 'Failed to get Synology session' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = buildSynologyFormBody({ ...params, _sid: session.sid });
|
|
||||||
const result = await fetchSynologyJson<T>(creds.synology_url, body);
|
|
||||||
if (!result.success && result.error?.code === 119) {
|
|
||||||
clearSynologySID(userId);
|
|
||||||
const retrySession = await getSynologySession(userId);
|
|
||||||
if (!retrySession.success || !retrySession.sid) {
|
|
||||||
return { success: false, error: retrySession.error || { code: 400, message: 'Failed to get Synology session' } };
|
|
||||||
}
|
|
||||||
return fetchSynologyJson<T>(creds.synology_url, buildSynologyFormBody({ ...params, _sid: retrySession.sid }));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestSynologyStream(url: string): Promise<globalThis.Response> {
|
|
||||||
return fetch(url, {
|
|
||||||
signal: AbortSignal.timeout(SYNOLOGY_API_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSynologyPhotoInfo(item: SynologyPhotoItem): SynologyPhotoInfo {
|
|
||||||
const address = item.additional?.address || {};
|
|
||||||
const exif = item.additional?.exif || {};
|
|
||||||
const gps = item.additional?.gps || {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(item.additional?.thumbnail?.cache_key || ''),
|
|
||||||
takenAt: item.time ? new Date(item.time * 1000).toISOString() : null,
|
|
||||||
city: address.city || null,
|
|
||||||
country: address.country || null,
|
|
||||||
state: address.state || null,
|
|
||||||
camera: exif.camera || null,
|
|
||||||
lens: exif.lens || null,
|
|
||||||
focalLength: exif.focal_length || null,
|
|
||||||
aperture: exif.aperture || null,
|
|
||||||
shutter: exif.exposure_time || null,
|
|
||||||
iso: exif.iso || null,
|
|
||||||
lat: gps.latitude || null,
|
|
||||||
lng: gps.longitude || null,
|
|
||||||
orientation: item.additional?.orientation || null,
|
|
||||||
description: item.additional?.description || null,
|
|
||||||
filename: item.filename || null,
|
|
||||||
filesize: item.filesize || null,
|
|
||||||
width: item.additional?.resolution?.width || null,
|
|
||||||
height: item.additional?.resolution?.height || null,
|
|
||||||
fileSize: item.filesize || null,
|
|
||||||
fileName: item.filename || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleSynologyError(res: ExpressResponse, err: unknown, fallbackMessage: string): ExpressResponse {
|
|
||||||
if (err instanceof SynologyServiceError) {
|
|
||||||
return res.status(err.status).json({ error: err.message });
|
|
||||||
}
|
|
||||||
return res.status(502).json({ error: err instanceof Error ? err.message : fallbackMessage });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cacheSynologySID(userId: number, sid: string): void {
|
|
||||||
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(sid, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSynologySID(userId: number): void {
|
|
||||||
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } {
|
|
||||||
const id = rawId.split('_')[0];
|
|
||||||
return { id, cacheKey: rawId, assetId: rawId };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function getSynologySession(userId: number): Promise<SynologySession> {
|
|
||||||
const cachedSid = readSynologyUser(userId, ['synology_sid'])?.synology_sid || null;
|
|
||||||
if (cachedSid) {
|
|
||||||
return { success: true, sid: cachedSid };
|
|
||||||
}
|
|
||||||
|
|
||||||
const creds = getSynologyCredentials(userId);
|
|
||||||
if (!creds) {
|
|
||||||
return { success: false, error: { code: 400, message: 'Invalid Synology credentials' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await loginToSynology(creds.synology_url, creds.synology_username, creds.synology_password);
|
|
||||||
|
|
||||||
if (!resp.success || !resp.data?.sid) {
|
|
||||||
return { success: false, error: resp.error || { code: 400, message: 'Failed to authenticate with Synology' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheSynologySID(userId, resp.data.sid);
|
|
||||||
return { success: true, sid: resp.data.sid };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSynologySettings(userId: number): Promise<SynologySettings> {
|
|
||||||
const creds = getSynologyCredentials(userId);
|
|
||||||
const session = await getSynologySession(userId);
|
|
||||||
return {
|
|
||||||
synology_url: creds?.synology_url || '',
|
|
||||||
synology_username: creds?.synology_username || '',
|
|
||||||
connected: session.success,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<void> {
|
|
||||||
|
|
||||||
const ssrf = await checkSsrf(synologyUrl);
|
|
||||||
if (!ssrf.allowed) {
|
|
||||||
throw new SynologyServiceError(400, ssrf.error ?? 'Invalid Synology URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingEncryptedPassword = readSynologyUser(userId, ['synology_password'])?.synology_password || null;
|
|
||||||
|
|
||||||
if (!synologyPassword && !existingEncryptedPassword) {
|
|
||||||
throw new SynologyServiceError(400, 'No stored password found. Please provide a password to save settings.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
|
|
||||||
synologyUrl,
|
|
||||||
synologyUsername,
|
|
||||||
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
throw new SynologyServiceError(400, 'Failed to save settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSynologySID(userId);
|
|
||||||
await getSynologySession(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSynologyStatus(userId: number): Promise<SynologyConnectionResult> {
|
|
||||||
try {
|
|
||||||
const sid = await getSynologySession(userId);
|
|
||||||
if (!sid.success || !sid.sid) {
|
|
||||||
return { connected: false, error: 'Authentication failed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
|
|
||||||
return { connected: true, user: { username: user?.synology_username || '' } };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<SynologyConnectionResult> {
|
|
||||||
|
|
||||||
const ssrf = await checkSsrf(synologyUrl);
|
|
||||||
if (!ssrf.allowed) {
|
|
||||||
return { connected: false, error: ssrf.error ?? 'Invalid Synology URL' };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const login = await loginToSynology(synologyUrl, synologyUsername, synologyPassword);
|
|
||||||
if (!login.success || !login.data?.sid) {
|
|
||||||
return { connected: false, error: login.error?.message || 'Authentication failed' };
|
|
||||||
}
|
|
||||||
return { connected: true, user: { username: synologyUsername } };
|
|
||||||
} catch (err: unknown) {
|
|
||||||
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSynologyAlbums(userId: number): Promise<{ albums: Array<{ id: string; albumName: string; assetCount: number }> }> {
|
|
||||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
|
||||||
api: 'SYNO.Foto.Browse.Album',
|
|
||||||
method: 'list',
|
|
||||||
version: 4,
|
|
||||||
offset: 0,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
throw new SynologyServiceError(result.error?.code || 500, result.error?.message || 'Failed to fetch albums');
|
|
||||||
}
|
|
||||||
|
|
||||||
const albums = (result.data.list || []).map((album: SynologyPhotoItem) => ({
|
|
||||||
id: String(album.id),
|
|
||||||
albumName: album.name || '',
|
|
||||||
assetCount: album.item_count || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { albums };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function syncSynologyAlbumLink(userId: number, tripId: string, linkId: string): Promise<{ added: number; total: number }> {
|
|
||||||
const link = db.prepare(`SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?`)
|
|
||||||
.get(linkId, tripId, userId, SYNOLOGY_PROVIDER) as { album_id?: string | number } | undefined;
|
|
||||||
|
|
||||||
if (!link) {
|
|
||||||
throw new SynologyServiceError(404, 'Album link not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems: SynologyPhotoItem[] = [];
|
|
||||||
const pageSize = 1000;
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(userId, {
|
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
|
||||||
method: 'list',
|
|
||||||
version: 1,
|
|
||||||
album_id: Number(link.album_id),
|
|
||||||
offset,
|
|
||||||
limit: pageSize,
|
|
||||||
additional: ['thumbnail'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album');
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = result.data.list || [];
|
|
||||||
allItems.push(...items);
|
|
||||||
if (items.length < pageSize) break;
|
|
||||||
offset += pageSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insert = db.prepare(
|
|
||||||
"INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'synologyphotos', 1)"
|
|
||||||
);
|
|
||||||
|
|
||||||
let added = 0;
|
|
||||||
for (const item of allItems) {
|
|
||||||
const transformed = normalizeSynologyPhotoInfo(item);
|
|
||||||
const assetId = String(transformed?.id || '').trim();
|
|
||||||
if (!assetId) continue;
|
|
||||||
const result = insert.run(tripId, userId, assetId);
|
|
||||||
if (result.changes > 0) added++;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
|
||||||
|
|
||||||
return { added, total: allItems.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchSynologyPhotos(userId: number, from?: string, to?: string, offset = 0, limit = 300): Promise<{ assets: SynologyPhotoInfo[]; total: number; hasMore: boolean }> {
|
|
||||||
const params: ApiCallParams = {
|
|
||||||
api: 'SYNO.Foto.Search.Search',
|
|
||||||
method: 'list_item',
|
|
||||||
version: 1,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
keyword: '.',
|
|
||||||
additional: ['thumbnail', 'address'],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (from || to) {
|
|
||||||
if (from) {
|
|
||||||
params.start_time = Math.floor(new Date(from).getTime() / 1000);
|
|
||||||
}
|
|
||||||
if (to) {
|
|
||||||
params.end_time = Math.floor(new Date(to).getTime() / 1000) + 86400; //adding it as the next day 86400 seconds in day
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[]; total: number }>(userId, params);
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
throw new SynologyServiceError(502, result.error?.message || 'Failed to fetch album photos');
|
|
||||||
}
|
|
||||||
|
|
||||||
const allItems = result.data.list || [];
|
|
||||||
const total = allItems.length;
|
|
||||||
const assets = allItems.map(item => normalizeSynologyPhotoInfo(item));
|
|
||||||
|
|
||||||
return {
|
|
||||||
assets,
|
|
||||||
total,
|
|
||||||
hasMore: total === limit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSynologyAssetInfo(userId: number, photoId: string, targetUserId?: number): Promise<SynologyPhotoInfo> {
|
|
||||||
const parsedId = splitPackedSynologyId(photoId);
|
|
||||||
const result = await requestSynologyApi<{ list: SynologyPhotoItem[] }>(targetUserId ?? userId, {
|
|
||||||
api: 'SYNO.Foto.Browse.Item',
|
|
||||||
method: 'get',
|
|
||||||
version: 5,
|
|
||||||
id: `[${parsedId.id}]`,
|
|
||||||
additional: ['resolution', 'exif', 'gps', 'address', 'orientation', 'description'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
throw new SynologyServiceError(404, 'Photo not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = result.data.list?.[0];
|
|
||||||
if (!metadata) {
|
|
||||||
throw new SynologyServiceError(404, 'Photo not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalized = normalizeSynologyPhotoInfo(metadata);
|
|
||||||
normalized.id = photoId;
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function streamSynologyAsset(
|
|
||||||
userId: number,
|
|
||||||
targetUserId: number,
|
|
||||||
photoId: string,
|
|
||||||
kind: 'thumbnail' | 'original',
|
|
||||||
size?: string,
|
|
||||||
): Promise<SynologyProxyResult> {
|
|
||||||
const parsedId = splitPackedSynologyId(photoId);
|
|
||||||
const synology_url = getSynologyCredentials(targetUserId).synology_url;
|
|
||||||
if (!synology_url) {
|
|
||||||
throw new SynologyServiceError(402, 'User not configured with Synology');
|
|
||||||
}
|
|
||||||
|
|
||||||
const sid = await getSynologySession(targetUserId);
|
|
||||||
if (!sid.success || !sid.sid) {
|
|
||||||
throw new SynologyServiceError(401, 'Authentication failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const params = kind === 'thumbnail'
|
|
||||||
? new URLSearchParams({
|
|
||||||
api: 'SYNO.Foto.Thumbnail',
|
|
||||||
method: 'get',
|
|
||||||
version: '2',
|
|
||||||
mode: 'download',
|
|
||||||
id: parsedId.id,
|
|
||||||
type: 'unit',
|
|
||||||
size: String(size || SYNOLOGY_DEFAULT_THUMBNAIL_SIZE),
|
|
||||||
cache_key: parsedId.cacheKey,
|
|
||||||
_sid: sid.sid,
|
|
||||||
})
|
|
||||||
: new URLSearchParams({
|
|
||||||
api: 'SYNO.Foto.Download',
|
|
||||||
method: 'download',
|
|
||||||
version: '2',
|
|
||||||
cache_key: parsedId.cacheKey,
|
|
||||||
unit_id: `[${parsedId.id}]`,
|
|
||||||
_sid: sid.sid,
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `${buildSynologyEndpoint(synology_url)}?${params.toString()}`;
|
|
||||||
const resp = await requestSynologyStream(url);
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
const body = kind === 'original' ? await resp.text() : 'Failed';
|
|
||||||
throw new SynologyServiceError(resp.status, kind === 'original' ? `Failed: ${body}` : body);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: resp.status,
|
|
||||||
headers: {
|
|
||||||
'content-type': resp.headers.get('content-type') || (kind === 'thumbnail' ? 'image/jpeg' : 'application/octet-stream'),
|
|
||||||
'cache-control': resp.headers.get('cache-control') || 'public, max-age=86400',
|
|
||||||
'content-length': resp.headers.get('content-length'),
|
|
||||||
'content-disposition': resp.headers.get('content-disposition'),
|
|
||||||
},
|
|
||||||
body: resp.body,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function pipeSynologyProxy(response: ExpressResponse, proxy: SynologyProxyResult): Promise<void> {
|
|
||||||
response.status(proxy.status);
|
|
||||||
if (proxy.headers['content-type']) response.set('Content-Type', proxy.headers['content-type'] as string);
|
|
||||||
if (proxy.headers['cache-control']) response.set('Cache-Control', proxy.headers['cache-control'] as string);
|
|
||||||
if (proxy.headers['content-length']) response.set('Content-Length', proxy.headers['content-length'] as string);
|
|
||||||
if (proxy.headers['content-disposition']) response.set('Content-Disposition', proxy.headers['content-disposition'] as string);
|
|
||||||
|
|
||||||
if (!proxy.body) {
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline(Readable.fromWeb(proxy.body), response);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user