Merge branch 'test' into dev
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise<string> {
|
||||
export async function getAuthUrl(url: string, purpose: string): Promise<string> {
|
||||
if (!url) return url
|
||||
try {
|
||||
const resp = await fetch('/api/auth/resource-token', {
|
||||
|
||||
@@ -15,7 +15,17 @@ interface Addon {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ProviderOption {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
toggle: () => Promise<void>
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
@@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState([])
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +63,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggle = async (addon) => {
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
@@ -68,9 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider'
|
||||
}
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const photosAddon = tripAddons.find(isPhotosAddon)
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -108,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
<AddonRow
|
||||
addon={addon}
|
||||
onToggle={handleToggle}
|
||||
t={t}
|
||||
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
|
||||
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
|
||||
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
|
||||
hideToggle={photosAddon && addon.id === photosAddon.id}
|
||||
/>
|
||||
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -171,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addonId: string) => void
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
@@ -187,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
@@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{label.name}</span>
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
Coming Soon
|
||||
@@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
<span className="hidden sm:inline text-xs font-medium" style={{ color: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: 'var(--bg-card)',
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,34 +1,44 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react'
|
||||
import apiClient from '../../api/client'
|
||||
import apiClient, { addonsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||
interface PhotoProvider {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(baseUrl, 'immich').then(setSrc)
|
||||
}, [baseUrl])
|
||||
getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {})
|
||||
}, [baseUrl, provider])
|
||||
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripPhoto {
|
||||
immich_asset_id: string
|
||||
asset_id: string
|
||||
provider: string
|
||||
user_id: number
|
||||
username: string
|
||||
shared: number
|
||||
added_at: string
|
||||
city?: string | null
|
||||
}
|
||||
|
||||
interface ImmichAsset {
|
||||
interface Asset {
|
||||
id: string
|
||||
takenAt: string
|
||||
city: string | null
|
||||
country: string | null
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface MemoriesPanelProps {
|
||||
@@ -45,6 +55,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [availableProviders, setAvailableProviders] = useState<PhotoProvider[]>([])
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Trip photos (saved selections)
|
||||
@@ -52,7 +64,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// Photo picker
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||
const [pickerPhotos, setPickerPhotos] = useState<Asset[]>([])
|
||||
const [pickerLoading, setPickerLoading] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -67,49 +79,61 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const [showAlbumPicker, setShowAlbumPicker] = useState(false)
|
||||
const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([])
|
||||
const [albumsLoading, setAlbumsLoading] = useState(false)
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([])
|
||||
const [syncing, setSyncing] = useState<number | null>(null)
|
||||
const pickerIntegrationBase = selectedProvider ? `/integrations/${selectedProvider}` : ''
|
||||
|
||||
const loadAlbumLinks = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||
const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
|
||||
setAlbumLinks(res.data.links || [])
|
||||
} catch { setAlbumLinks([]) }
|
||||
}
|
||||
|
||||
const openAlbumPicker = async () => {
|
||||
setShowAlbumPicker(true)
|
||||
const loadAlbums = async (provider: string = selectedProvider) => {
|
||||
if (!provider) return
|
||||
setAlbumsLoading(true)
|
||||
try {
|
||||
const res = await apiClient.get('/integrations/immich/albums')
|
||||
const res = await apiClient.get(`/integrations/${provider}/albums`)
|
||||
setAlbums(res.data.albums || [])
|
||||
} catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
|
||||
finally { setAlbumsLoading(false) }
|
||||
} catch {
|
||||
setAlbums([])
|
||||
toast.error(t('memories.error.loadAlbums'))
|
||||
} finally {
|
||||
setAlbumsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openAlbumPicker = async () => {
|
||||
setShowAlbumPicker(true)
|
||||
await loadAlbums(selectedProvider)
|
||||
}
|
||||
|
||||
const linkAlbum = async (albumId: string, albumName: string) => {
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
|
||||
await apiClient.post(`${pickerIntegrationBase}/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
|
||||
setShowAlbumPicker(false)
|
||||
await loadAlbumLinks()
|
||||
// Auto-sync after linking
|
||||
const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
|
||||
const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
|
||||
const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
|
||||
if (newLink) await syncAlbum(newLink.id)
|
||||
} catch { toast.error(t('memories.error.linkAlbum')) }
|
||||
}
|
||||
|
||||
const unlinkAlbum = async (linkId: number) => {
|
||||
try {
|
||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
|
||||
await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`)
|
||||
loadAlbumLinks()
|
||||
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
||||
}
|
||||
|
||||
const syncAlbum = async (linkId: number) => {
|
||||
const syncAlbum = async (linkId: number, provider?: string) => {
|
||||
const targetProvider = provider || selectedProvider
|
||||
if (!targetProvider) return
|
||||
setSyncing(linkId)
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
|
||||
await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`)
|
||||
await loadAlbumLinks()
|
||||
await loadPhotos()
|
||||
} catch { toast.error(t('memories.error.syncAlbum')) }
|
||||
@@ -138,7 +162,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
const loadPhotos = async () => {
|
||||
try {
|
||||
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||
const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`)
|
||||
setTripPhotos(photosRes.data.photos || [])
|
||||
} catch {
|
||||
setTripPhotos([])
|
||||
@@ -148,9 +172,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const loadInitial = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||
setConnected(statusRes.data.connected)
|
||||
const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] }))
|
||||
const enabledAddons = addonsRes?.addons || []
|
||||
const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled)
|
||||
|
||||
// Test connection status for each enabled provider
|
||||
const statusResults = await Promise.all(
|
||||
photoProviders.map(async (provider: any) => {
|
||||
const statusUrl = (provider.config as Record<string, unknown>)?.status_get as string
|
||||
if (!statusUrl) return { provider, connected: false }
|
||||
try {
|
||||
const res = await apiClient.get(statusUrl)
|
||||
return { provider, connected: !!res.data?.connected }
|
||||
} catch {
|
||||
return { provider, connected: false }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const connectedProviders = statusResults
|
||||
.filter(r => r.connected)
|
||||
.map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config }))
|
||||
|
||||
setAvailableProviders(connectedProviders)
|
||||
setConnected(connectedProviders.length > 0)
|
||||
if (connectedProviders.length > 0 && !selectedProvider) {
|
||||
setSelectedProvider(connectedProviders[0].id)
|
||||
}
|
||||
} catch {
|
||||
setAvailableProviders([])
|
||||
setConnected(false)
|
||||
}
|
||||
await loadPhotos()
|
||||
@@ -170,14 +220,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
await loadPickerPhotos(!!(startDate && endDate))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showPicker) {
|
||||
loadPickerPhotos(pickerDateFilter)
|
||||
}
|
||||
}, [selectedProvider])
|
||||
|
||||
useEffect(() => {
|
||||
loadAlbumLinks()
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (showAlbumPicker) {
|
||||
loadAlbums(selectedProvider)
|
||||
}
|
||||
}, [showAlbumPicker, selectedProvider, tripId])
|
||||
|
||||
const loadPickerPhotos = async (useDate: boolean) => {
|
||||
setPickerLoading(true)
|
||||
try {
|
||||
const res = await apiClient.post('/integrations/immich/search', {
|
||||
const provider = availableProviders.find(p => p.id === selectedProvider)
|
||||
if (!provider) {
|
||||
setPickerPhotos([])
|
||||
return
|
||||
}
|
||||
const res = await apiClient.post(`/integrations/${provider.id}/search`, {
|
||||
from: useDate && startDate ? startDate : undefined,
|
||||
to: useDate && endDate ? endDate : undefined,
|
||||
})
|
||||
setPickerPhotos(res.data.assets || [])
|
||||
setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id })))
|
||||
} catch {
|
||||
setPickerPhotos([])
|
||||
toast.error(t('memories.error.loadPhotos'))
|
||||
@@ -203,8 +274,17 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
const executeAddPhotos = async () => {
|
||||
setShowConfirmShare(false)
|
||||
try {
|
||||
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||
asset_ids: [...selectedIds],
|
||||
const groupedByProvider = new Map<string, string[]>()
|
||||
for (const key of selectedIds) {
|
||||
const [provider, assetId] = key.split('::')
|
||||
if (!provider || !assetId) continue
|
||||
const list = groupedByProvider.get(provider) || []
|
||||
list.push(assetId)
|
||||
groupedByProvider.set(provider, list)
|
||||
}
|
||||
|
||||
await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, {
|
||||
selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })),
|
||||
shared: true,
|
||||
})
|
||||
setShowPicker(false)
|
||||
@@ -214,28 +294,39 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||
|
||||
const removePhoto = async (assetId: string) => {
|
||||
const removePhoto = async (photo: TripPhoto) => {
|
||||
try {
|
||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||
await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, {
|
||||
data: {
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
},
|
||||
})
|
||||
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
|
||||
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||
}
|
||||
|
||||
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||
|
||||
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||
const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
|
||||
try {
|
||||
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||
await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, {
|
||||
shared,
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
})
|
||||
setTripPhotos(prev => prev.map(p =>
|
||||
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
))
|
||||
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const thumbnailBaseUrl = (assetId: string, userId: number) =>
|
||||
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
|
||||
const thumbnailBaseUrl = (photo: TripPhoto) =>
|
||||
`/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}`
|
||||
|
||||
const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}`
|
||||
|
||||
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||
@@ -286,10 +377,40 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||
|
||||
const ProviderTabs = () => {
|
||||
if (availableProviders.length < 2) return null
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
{availableProviders.map(provider => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => setSelectedProvider(provider.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 99,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
border: '1px solid',
|
||||
transition: 'all 0.15s',
|
||||
background: selectedProvider === provider.id ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: selectedProvider === provider.id ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: selectedProvider === provider.id ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{provider.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Album Picker Modal ──────────────────────────────────────────────────
|
||||
|
||||
if (showAlbumPicker) {
|
||||
const linkedIds = new Set(albumLinks.map(l => l.immich_album_id))
|
||||
const linkedIds = new Set(albumLinks.map(l => l.album_id))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
@@ -297,6 +418,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.selectAlbum')}
|
||||
</h3>
|
||||
<ProviderTabs />
|
||||
<button onClick={() => setShowAlbumPicker(false)}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
@@ -353,7 +475,11 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||
|
||||
if (showPicker) {
|
||||
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||
const alreadyAdded = new Set(
|
||||
tripPhotos
|
||||
.filter(p => p.user_id === currentUser?.id)
|
||||
.map(p => makePickerKey(p.provider, p.asset_id))
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -364,6 +490,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{t('memories.selectPhotos')}
|
||||
</h3>
|
||||
<ProviderTabs />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
@@ -426,7 +553,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</div>
|
||||
) : (() => {
|
||||
// Group photos by month
|
||||
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||
const byMonth: Record<string, Asset[]> = {}
|
||||
for (const asset of pickerPhotos) {
|
||||
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||
@@ -444,11 +571,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||
{byMonth[month].map(asset => {
|
||||
const isSelected = selectedIds.has(asset.id)
|
||||
const isAlready = alreadyAdded.has(asset.id)
|
||||
const pickerKey = makePickerKey(asset.provider, asset.id)
|
||||
const isSelected = selectedIds.has(pickerKey)
|
||||
const isAlready = alreadyAdded.has(pickerKey)
|
||||
return (
|
||||
<div key={asset.id}
|
||||
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||
<div key={pickerKey}
|
||||
onClick={() => !isAlready && togglePickerSelect(pickerKey)}
|
||||
style={{
|
||||
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||
cursor: isAlready ? 'default' : 'pointer',
|
||||
@@ -456,7 +584,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||
outlineOffset: -3,
|
||||
}}>
|
||||
<ImmichImg baseUrl={thumbnailBaseUrl(asset.id, currentUser!.id)} loading="lazy"
|
||||
<ProviderImg baseUrl={`/api/integrations/${asset.provider}/assets/${asset.id}/thumbnail?userId=${currentUser!.id}`} provider={asset.provider} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
{isSelected && (
|
||||
<div style={{
|
||||
@@ -564,7 +692,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<FolderOpen size={11} />
|
||||
<span style={{ fontWeight: 500 }}>{link.album_name}</span>
|
||||
{link.username !== currentUser?.username && <span style={{ color: 'var(--text-faint)' }}>({link.username})</span>}
|
||||
<button onClick={() => syncAlbum(link.id)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
|
||||
<button onClick={() => syncAlbum(link.id, link.provider)} disabled={syncing === link.id} title={t('memories.syncAlbum')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: 'var(--text-faint)' }}>
|
||||
<RefreshCw size={11} style={{ animation: syncing === link.id ? 'spin 1s linear infinite' : 'none' }} />
|
||||
</button>
|
||||
@@ -630,18 +758,18 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{allVisible.map(photo => {
|
||||
const isOwn = photo.user_id === currentUser?.id
|
||||
return (
|
||||
<div key={photo.immich_asset_id} className="group"
|
||||
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
|
||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
setLightboxOriginalSrc('')
|
||||
getAuthUrl(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`, 'immich').then(setLightboxOriginalSrc)
|
||||
getAuthUrl(`/api/integrations/${photo.provider}/assets/${photo.asset_id}/original?userId=${photo.user_id}`, photo.provider).then(setLightboxOriginalSrc).catch(() => {})
|
||||
setLightboxInfoLoading(true)
|
||||
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||
apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`)
|
||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||
}}>
|
||||
|
||||
<ImmichImg baseUrl={thumbnailBaseUrl(photo.immich_asset_id, photo.user_id)} loading="lazy"
|
||||
<ProviderImg baseUrl={thumbnailBaseUrl(photo)} provider={photo.provider} loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||
|
||||
{/* Other user's avatar */}
|
||||
@@ -672,7 +800,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{isOwn && (
|
||||
<div className="opacity-0 group-hover:opacity-100"
|
||||
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
|
||||
<button onClick={e => { e.stopPropagation(); toggleSharing(photo, !photo.shared) }}
|
||||
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
@@ -681,7 +809,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
}}>
|
||||
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||
<button onClick={e => { e.stopPropagation(); removePhoto(photo) }}
|
||||
style={{
|
||||
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||
|
||||
@@ -30,6 +30,35 @@ interface McpToken {
|
||||
last_used_at: string | null
|
||||
}
|
||||
|
||||
interface ProviderField {
|
||||
key: string
|
||||
label: string
|
||||
input_type: string
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
settings_key?: string | null
|
||||
payload_key?: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface PhotoProviderAddon {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
fields?: ProviderField[]
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
settings_get?: string
|
||||
settings_put?: string
|
||||
status_get?: string
|
||||
test_get?: string
|
||||
test_post?: string
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
@@ -116,7 +145,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||
const { isEnabled: addonEnabled, loadAddons, addons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
@@ -130,10 +159,10 @@ export default function SettingsPage(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
}, [])
|
||||
const [immichUrl, setImmichUrl] = useState('')
|
||||
const [immichApiKey, setImmichApiKey] = useState('')
|
||||
const [immichConnected, setImmichConnected] = useState(false)
|
||||
const [immichTesting, setImmichTesting] = useState(false)
|
||||
const activePhotoProviders = addons.filter(a => a.type === 'photo_provider' && a.enabled)
|
||||
const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({})
|
||||
const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({})
|
||||
const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleMapClick = useCallback((mapInfo) => {
|
||||
setDefaultLat(mapInfo.latlng.lat)
|
||||
@@ -143,49 +172,123 @@ export default function SettingsPage(): React.ReactElement {
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (memoriesEnabled) {
|
||||
apiClient.get('/integrations/immich/settings').then(r2 => {
|
||||
setImmichUrl(r2.data.immich_url || '')
|
||||
setImmichConnected(r2.data.connected)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [memoriesEnabled])
|
||||
|
||||
const [immichTestPassed, setImmichTestPassed] = useState(false)
|
||||
|
||||
const handleSaveImmich = async () => {
|
||||
setSaving(s => ({ ...s, immich: true }))
|
||||
try {
|
||||
const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
if (saveRes.data.warning) toast.warning(saveRes.data.warning)
|
||||
toast.success(t('memories.saved'))
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
setImmichConnected(res.data.connected)
|
||||
setImmichTestPassed(false)
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, immich: false }))
|
||||
const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => {
|
||||
const raw = provider.config || {}
|
||||
return {
|
||||
settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined,
|
||||
settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined,
|
||||
status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined,
|
||||
test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined,
|
||||
test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestImmich = async () => {
|
||||
setImmichTesting(true)
|
||||
const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => {
|
||||
return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
const buildProviderPayload = (provider: PhotoProviderAddon): Record<string, unknown> => {
|
||||
const values = providerValues[provider.id] || {}
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of getProviderFields(provider)) {
|
||||
const payloadKey = field.payload_key || field.settings_key || field.key
|
||||
const value = (values[field.key] || '').trim()
|
||||
if (field.secret && !value) continue
|
||||
payload[payloadKey] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const refreshProviderConnection = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const statusPath = cfg.status_get
|
||||
if (!statusPath) return
|
||||
try {
|
||||
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
|
||||
if (res.data.connected) {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
setImmichTestPassed(true)
|
||||
const res = await apiClient.get(statusPath)
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected }))
|
||||
} catch {
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const activeProviderSignature = activePhotoProviders.map(p => p.id).join('|')
|
||||
|
||||
useEffect(() => {
|
||||
for (const provider of activePhotoProviders as PhotoProviderAddon[]) {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const fields = getProviderFields(provider)
|
||||
if (cfg.settings_get) {
|
||||
apiClient.get(cfg.settings_get).then(res => {
|
||||
const nextValues: Record<string, string> = {}
|
||||
for (const field of fields) {
|
||||
// Don't populate secret fields into state - they should remain empty until user edits
|
||||
if (field.secret) continue
|
||||
const sourceKey = field.settings_key || field.payload_key || field.key
|
||||
const rawValue = (res.data as Record<string, unknown>)[sourceKey]
|
||||
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : ''
|
||||
}
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[provider.id]: { ...(prev[provider.id] || {}), ...nextValues },
|
||||
}))
|
||||
if (typeof res.data?.connected === 'boolean') {
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected }))
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
refreshProviderConnection(provider).catch(() => {})
|
||||
}
|
||||
}, [activeProviderSignature])
|
||||
|
||||
const handleProviderFieldChange = (providerId: string, key: string, value: string) => {
|
||||
setProviderValues(prev => ({
|
||||
...prev,
|
||||
[providerId]: { ...(prev[providerId] || {}), [key]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => {
|
||||
const values = providerValues[provider.id] || {}
|
||||
return getProviderFields(provider).some(field => {
|
||||
if (!field.required) return false
|
||||
return !(values[field.key] || '').trim()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveProvider = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
if (!cfg.settings_put) return
|
||||
setSaving(s => ({ ...s, [provider.id]: true }))
|
||||
try {
|
||||
await apiClient.put(cfg.settings_put, buildProviderPayload(provider))
|
||||
await refreshProviderConnection(provider)
|
||||
toast.success(`${provider.name} settings saved`)
|
||||
} catch {
|
||||
toast.error(`Could not save ${provider.name} settings`)
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestProvider = async (provider: PhotoProviderAddon) => {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const testPath = cfg.test_post || cfg.test_get || cfg.status_get
|
||||
if (!testPath) return
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: true }))
|
||||
try {
|
||||
const payload = buildProviderPayload(provider)
|
||||
const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath)
|
||||
const ok = !!res.data?.connected
|
||||
setProviderConnected(prev => ({ ...prev, [provider.id]: ok }))
|
||||
if (ok) {
|
||||
toast.success(`${provider.name} connected`)
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||
setImmichTestPassed(false)
|
||||
toast.error(`${provider.name} connection failed${res.data?.error ? `: ${String(res.data.error)}` : ''}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
toast.error(`${provider.name} connection failed`)
|
||||
} finally {
|
||||
setImmichTesting(false)
|
||||
setProviderTesting(prev => ({ ...prev, [provider.id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +353,62 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}`
|
||||
|
||||
const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => {
|
||||
const fields = getProviderFields(provider)
|
||||
const cfg = getProviderConfig(provider)
|
||||
const values = providerValues[provider.id] || {}
|
||||
const connected = !!providerConnected[provider.id]
|
||||
const testing = !!providerTesting[provider.id]
|
||||
const canSave = !!cfg.settings_put
|
||||
const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get)
|
||||
|
||||
return (
|
||||
<Section key={provider.id} title={provider.name || provider.id} icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{field.label}</label>
|
||||
<input
|
||||
type={field.input_type || 'text'}
|
||||
value={values[field.key] || ''}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
|
||||
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
title={!canTest ? 'Test route is not configured for this provider' : ''}
|
||||
>
|
||||
{testing
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{connected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// Map settings
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
@@ -732,45 +891,7 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<NotificationPreferences t={t} memoriesEnabled={memoriesEnabled} />
|
||||
</Section>
|
||||
|
||||
{/* Immich — only when Memories addon is enabled */}
|
||||
{memoriesEnabled && (
|
||||
<Section title="Immich" icon={Camera}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||
<input type="url" value={immichUrl} onChange={e => { setImmichUrl(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder="https://immich.example.com"
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||
<input type="password" value={immichApiKey} onChange={e => { setImmichApiKey(e.target.value); setImmichTestPassed(false) }}
|
||||
placeholder={immichConnected ? '••••••••' : 'API Key'}
|
||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleSaveImmich} disabled={saving.immich || !immichTestPassed}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!immichTestPassed ? t('memories.testFirst') : ''}>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50">
|
||||
{immichTesting
|
||||
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||
: <Camera className="w-4 h-4" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{immichConnected && (
|
||||
<span className="text-xs font-medium text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('memories.connected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{activePhotoProviders.map(provider => renderPhotoProviderSection(provider as PhotoProviderAddon))}
|
||||
|
||||
{/* MCP Configuration — only when MCP addon is enabled */}
|
||||
{mcpEnabled && <Section title={t('settings.mcp.title')} icon={Terminal}>
|
||||
|
||||
@@ -78,7 +78,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories })
|
||||
// Check if any photo provider is enabled (for memories tab to show)
|
||||
const hasPhotoProviders = data.addons.some(a => a.type === 'photo_provider' && a.enabled)
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories || hasPhotoProviders })
|
||||
}).catch(() => {})
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
|
||||
@@ -4,9 +4,22 @@ import { addonsApi } from '../api/client'
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
type: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
config?: Record<string, unknown>
|
||||
fields?: Array<{
|
||||
key: string
|
||||
label: string
|
||||
input_type: string
|
||||
placeholder?: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
settings_key?: string | null
|
||||
payload_key?: string | null
|
||||
sort_order: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface AddonState {
|
||||
@@ -30,6 +43,9 @@ export const useAddonStore = create<AddonState>((set, get) => ({
|
||||
},
|
||||
|
||||
isEnabled: (id: string) => {
|
||||
if (id === 'memories') {
|
||||
return get().addons.some(a => a.type === 'photo_provider' && a.enabled)
|
||||
}
|
||||
return get().addons.some(a => a.id === id && a.enabled)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -518,6 +518,179 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC);
|
||||
`);
|
||||
},
|
||||
() => {
|
||||
// Normalize trip_photos to provider-based schema used by current routes
|
||||
const tripPhotosExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'").get();
|
||||
if (!tripPhotosExists) {
|
||||
db.exec(`
|
||||
CREATE TABLE trip_photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
asset_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'immich',
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, asset_id, provider)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id);
|
||||
`);
|
||||
} else {
|
||||
const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>;
|
||||
const names = new Set(columns.map(c => c.name));
|
||||
const assetSource = names.has('asset_id') ? 'asset_id' : (names.has('immich_asset_id') ? 'immich_asset_id' : null);
|
||||
if (assetSource) {
|
||||
const providerExpr = names.has('provider')
|
||||
? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END"
|
||||
: "'immich'";
|
||||
const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1';
|
||||
const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE trip_photos_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
asset_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'immich',
|
||||
shared INTEGER NOT NULL DEFAULT 1,
|
||||
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, asset_id, provider)
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at)
|
||||
SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr}
|
||||
FROM trip_photos
|
||||
WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != ''
|
||||
`);
|
||||
|
||||
db.exec('DROP TABLE trip_photos');
|
||||
db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)');
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Normalize trip_album_links to provider + album_id schema used by current routes
|
||||
const linksExists = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'").get();
|
||||
if (!linksExists) {
|
||||
db.exec(`
|
||||
CREATE TABLE trip_album_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
album_id TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL DEFAULT '',
|
||||
sync_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, provider, album_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id);
|
||||
`);
|
||||
} else {
|
||||
const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>;
|
||||
const names = new Set(columns.map(c => c.name));
|
||||
const albumIdSource = names.has('album_id') ? 'album_id' : (names.has('immich_album_id') ? 'immich_album_id' : null);
|
||||
if (albumIdSource) {
|
||||
const providerExpr = names.has('provider')
|
||||
? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END"
|
||||
: "'immich'";
|
||||
const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''";
|
||||
const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1';
|
||||
const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL';
|
||||
const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE trip_album_links_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
album_id TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL DEFAULT '',
|
||||
sync_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, provider, album_id)
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at)
|
||||
SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr}
|
||||
FROM trip_album_links
|
||||
WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != ''
|
||||
`);
|
||||
|
||||
db.exec('DROP TABLE trip_album_links');
|
||||
db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)');
|
||||
}
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Add Synology credential columns for existing databases
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
() => {
|
||||
// Seed Synology Photos provider and fields in existing databases
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO photo_providers (id, name, description, icon, enabled, config, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
icon = excluded.icon,
|
||||
enabled = excluded.enabled,
|
||||
config = excluded.config,
|
||||
sort_order = excluded.sort_order
|
||||
`).run(
|
||||
'synologyphotos',
|
||||
'Synology Photos',
|
||||
'Synology Photos integration with separate account settings',
|
||||
'Image',
|
||||
0,
|
||||
JSON.stringify({
|
||||
settings_get: '/integrations/synologyphotos/settings',
|
||||
settings_put: '/integrations/synologyphotos/settings',
|
||||
status_get: '/integrations/synologyphotos/status',
|
||||
test_post: '/integrations/synologyphotos/test',
|
||||
}),
|
||||
1,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('no such table')) throw err;
|
||||
}
|
||||
try {
|
||||
const insertField = db.prepare(`
|
||||
INSERT INTO photo_provider_fields
|
||||
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id, field_key) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
input_type = excluded.input_type,
|
||||
placeholder = excluded.placeholder,
|
||||
required = excluded.required,
|
||||
secret = excluded.secret,
|
||||
settings_key = excluded.settings_key,
|
||||
payload_key = excluded.payload_key,
|
||||
sort_order = excluded.sort_order
|
||||
`);
|
||||
insertField.run('synologyphotos', 'synology_url', 'Server URL', 'url', 'https://synology.example.com', 1, 0, 'synology_url', 'synology_url', 0);
|
||||
insertField.run('synologyphotos', 'synology_username', 'Username', 'text', 'Username', 1, 0, 'synology_username', 'synology_username', 1);
|
||||
insertField.run('synologyphotos', 'synology_password', 'Password', 'password', 'Password', 1, 1, null, 'synology_password', 2);
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('no such table')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -18,6 +18,10 @@ function createTables(db: Database.Database): void {
|
||||
mfa_enabled INTEGER DEFAULT 0,
|
||||
mfa_secret TEXT,
|
||||
mfa_backup_codes TEXT,
|
||||
synology_url TEXT,
|
||||
synology_username TEXT,
|
||||
synology_password TEXT,
|
||||
synology_sid TEXT,
|
||||
must_change_password INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -222,6 +226,31 @@ function createTables(db: Database.Database): void {
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT DEFAULT 'Image',
|
||||
enabled INTEGER DEFAULT 0,
|
||||
config TEXT DEFAULT '{}',
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photo_provider_fields (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE,
|
||||
field_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
input_type TEXT NOT NULL DEFAULT 'text',
|
||||
placeholder TEXT,
|
||||
required INTEGER DEFAULT 0,
|
||||
secret INTEGER DEFAULT 0,
|
||||
settings_key TEXT,
|
||||
payload_key TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
UNIQUE(provider_id, field_key)
|
||||
);
|
||||
|
||||
-- Vacay addon tables
|
||||
CREATE TABLE IF NOT EXISTS vacay_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -92,6 +92,51 @@ function seedAddons(db: Database.Database): void {
|
||||
];
|
||||
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
|
||||
|
||||
const providerRows = [
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
description: 'Immich photo provider',
|
||||
icon: 'Image',
|
||||
enabled: 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',
|
||||
name: 'Synology Photos',
|
||||
description: 'Synology Photos integration with separate account settings',
|
||||
icon: 'Image',
|
||||
enabled: 0,
|
||||
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 (?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const p of providerRows) insertProvider.run(p.id, p.name, p.description, p.icon, p.enabled, p.config, p.sort_order);
|
||||
|
||||
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_api_key', label: 'API Key', input_type: 'password', placeholder: 'API Key', required: 1, secret: 1, settings_key: null, payload_key: 'immich_api_key', sort_order: 1 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_url', label: 'Server URL', input_type: 'url', placeholder: 'https://synology.example.com', required: 1, secret: 0, settings_key: 'synology_url', payload_key: 'synology_url', sort_order: 0 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_username', label: 'Username', input_type: 'text', placeholder: 'Username', required: 1, secret: 0, settings_key: 'synology_username', payload_key: 'synology_username', sort_order: 1 },
|
||||
{ provider_id: 'synologyphotos', field_key: 'synology_password', label: 'Password', input_type: 'password', placeholder: 'Password', required: 1, secret: 1, settings_key: null, payload_key: 'synology_password', sort_order: 2 },
|
||||
];
|
||||
const insertProviderField = db.prepare('INSERT OR IGNORE INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const f of providerFields) {
|
||||
insertProviderField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.required, f.secret, f.settings_key, f.payload_key, f.sort_order);
|
||||
}
|
||||
console.log('Default addons seeded');
|
||||
} catch (err: unknown) {
|
||||
console.error('Error seeding addons:', err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate, adminOnly } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { db } from '../db/database';
|
||||
import { AuthRequest, Addon } from '../types';
|
||||
import { writeAudit, getClientIp, logInfo } from '../services/auditLog';
|
||||
import * as svc from '../services/adminService';
|
||||
|
||||
@@ -264,21 +265,100 @@ router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res
|
||||
// ── Addons ─────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/addons', (_req: Request, res: Response) => {
|
||||
res.json({ addons: svc.listAddons() });
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
const providers = db.prepare(`
|
||||
SELECT id, name, description, icon, enabled, config, sort_order
|
||||
FROM photo_providers
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>;
|
||||
const fields = db.prepare(`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
}
|
||||
|
||||
res.json({
|
||||
addons: [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
|
||||
...providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: JSON.parse(p.config || '{}'),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
payload_key: f.payload_key || null,
|
||||
sort_order: f.sort_order,
|
||||
})),
|
||||
sort_order: p.sort_order,
|
||||
})),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/addons/:id', (req: Request, res: Response) => {
|
||||
const result = svc.updateAddon(req.params.id, req.body);
|
||||
if ('error' in result) return res.status(result.status!).json({ error: result.error });
|
||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined;
|
||||
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
|
||||
if (!addon && !provider) return res.status(404).json({ error: 'Addon not found' });
|
||||
const { enabled, config } = req.body;
|
||||
if (addon) {
|
||||
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
||||
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||
} else {
|
||||
if (enabled !== undefined) db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
|
||||
if (config !== undefined) db.prepare('UPDATE photo_providers SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
|
||||
}
|
||||
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon | undefined;
|
||||
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(req.params.id) as { id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number } | undefined;
|
||||
const updated = updatedAddon
|
||||
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
||||
: updatedProvider
|
||||
? {
|
||||
id: updatedProvider.id,
|
||||
name: updatedProvider.name,
|
||||
description: updatedProvider.description,
|
||||
type: 'photo_provider',
|
||||
icon: updatedProvider.icon,
|
||||
enabled: !!updatedProvider.enabled,
|
||||
config: JSON.parse(updatedProvider.config || '{}'),
|
||||
sort_order: updatedProvider.sort_order,
|
||||
}
|
||||
: null;
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.addon_update',
|
||||
resource: String(req.params.id),
|
||||
ip: getClientIp(req),
|
||||
details: result.auditDetails,
|
||||
details: { enabled: req.body.enabled, config: req.body.config },
|
||||
});
|
||||
res.json({ addon: result.addon });
|
||||
res.json({ addon: updated });
|
||||
});
|
||||
|
||||
// ── MCP Tokens ─────────────────────────────────────────────────────────────
|
||||
@@ -300,12 +380,9 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
user_id: authReq.user?.id ?? null,
|
||||
username: authReq.user?.username ?? 'unknown',
|
||||
userId: authReq.user?.id ?? null,
|
||||
action: 'admin.rotate_jwt_secret',
|
||||
target_type: 'system',
|
||||
target_id: null,
|
||||
details: null,
|
||||
resource: 'system',
|
||||
ip: getClientIp(req),
|
||||
});
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -317,9 +317,13 @@ router.post('/ws-token', authenticate, (req: Request, res: Response) => {
|
||||
// Short-lived single-use token for direct resource URLs
|
||||
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const result = createResourceToken(authReq.user.id, req.body.purpose);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json({ token: result.token });
|
||||
const { purpose } = req.body as { purpose?: string };
|
||||
if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') {
|
||||
return res.status(400).json({ error: 'Invalid purpose' });
|
||||
}
|
||||
const token = createResourceToken(authReq.user.id, purpose);
|
||||
if (!token) return res.status(503).json({ error: 'Service unavailable' });
|
||||
res.json(token);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
const router = express.Router();
|
||||
|
||||
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
|
||||
|
||||
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
if (queryToken) {
|
||||
@@ -88,7 +87,7 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
res.json({ assets: result.assets });
|
||||
});
|
||||
|
||||
// ── Trip Photos (selected by user) ────────────────────────────────────────
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
@@ -97,57 +96,6 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
||||
res.json({ photos: listTripPhotos(tripId, authReq.user.id) });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { asset_ids, shared = true } = req.body;
|
||||
|
||||
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'asset_ids required' });
|
||||
}
|
||||
|
||||
const added = addTripPhotos(tripId, authReq.user.id, asset_ids, shared);
|
||||
res.json({ success: true, added });
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
|
||||
// Notify trip members about shared photos
|
||||
if (shared && added > 0) {
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, count: String(added) }).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
removeTripPhoto(req.params.tripId, authReq.user.id, req.params.assetId);
|
||||
res.json({ success: true });
|
||||
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { shared } = req.body;
|
||||
togglePhotoSharing(req.params.tripId, authReq.user.id, req.params.assetId, shared);
|
||||
res.json({ success: true });
|
||||
broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
// ── Asset Details ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { assetId } = req.params;
|
||||
if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' });
|
||||
const result = await getAssetInfo(authReq.user.id, assetId);
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
res.json(result.data);
|
||||
});
|
||||
|
||||
// ── Proxy Immich Assets ────────────────────────────────────────────────────
|
||||
|
||||
router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => {
|
||||
@@ -181,12 +129,6 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
res.json({ albums: result.albums });
|
||||
});
|
||||
|
||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
res.json({ links: listAlbumLinks(req.params.tripId) });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
@@ -198,12 +140,6 @@ router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
deleteAlbumLink(req.params.linkId, req.params.tripId, authReq.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
|
||||
192
server/src/routes/memories.ts
Normal file
192
server/src/routes/memories.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest } from '../types';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
||||
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
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, authReq.user.id) as any[];
|
||||
|
||||
res.json({ photos });
|
||||
});
|
||||
|
||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(linkId, tripId, authReq.user.id);
|
||||
|
||||
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 { shared = true } = req.body;
|
||||
const selectionsRaw = Array.isArray(req.body?.selections) ? req.body.selections : null;
|
||||
const provider = String(req.body?.provider || '').toLowerCase();
|
||||
const assetIdsRaw = req.body?.asset_ids;
|
||||
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
const selections = selectionsRaw && selectionsRaw.length > 0
|
||||
? selectionsRaw
|
||||
.map((selection: any) => ({
|
||||
provider: String(selection?.provider || '').toLowerCase(),
|
||||
asset_ids: Array.isArray(selection?.asset_ids) ? selection.asset_ids : [],
|
||||
}))
|
||||
.filter((selection: { provider: string; asset_ids: unknown[] }) => selection.provider && selection.asset_ids.length > 0)
|
||||
: (provider && Array.isArray(assetIdsRaw) && assetIdsRaw.length > 0
|
||||
? [{ provider, asset_ids: assetIdsRaw }]
|
||||
: []);
|
||||
|
||||
if (selections.length === 0) {
|
||||
return res.status(400).json({ error: 'selections required' });
|
||||
}
|
||||
|
||||
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, authReq.user.id, assetId, selection.provider, shared ? 1 : 0);
|
||||
if (result.changes > 0) added++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, added });
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
|
||||
if (shared && added > 0) {
|
||||
import('../services/notifications').then(({ notifyTripMembers }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', {
|
||||
trip: tripInfo?.title || 'Untitled',
|
||||
actor: authReq.user.username || authReq.user.email,
|
||||
count: String(added),
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const provider = String(req.body?.provider || '').toLowerCase();
|
||||
const assetId = String(req.body?.asset_id || '');
|
||||
|
||||
if (!assetId) {
|
||||
return res.status(400).json({ error: 'asset_id is required' });
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return res.status(400).json({ error: 'provider is required' });
|
||||
}
|
||||
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM trip_photos
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(tripId, authReq.user.id, assetId, provider);
|
||||
|
||||
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 provider = String(req.body?.provider || '').toLowerCase();
|
||||
const assetId = String(req.body?.asset_id || '');
|
||||
const { shared } = req.body;
|
||||
|
||||
if (!assetId) {
|
||||
return res.status(400).json({ error: 'asset_id is required' });
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return res.status(400).json({ error: 'provider is required' });
|
||||
}
|
||||
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) {
|
||||
return res.status(404).json({ error: 'Trip not found' });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE trip_photos
|
||||
SET shared = ?
|
||||
WHERE trip_id = ?
|
||||
AND user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = ?
|
||||
`).run(shared ? 1 : 0, tripId, authReq.user.id, assetId, provider);
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
export default router;
|
||||
183
server/src/routes/synology.ts
Normal file
183
server/src/routes/synology.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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,
|
||||
linkSynologyAlbum,
|
||||
syncSynologyAlbumLink,
|
||||
searchSynologyPhotos,
|
||||
getSynologyAssetInfo,
|
||||
pipeSynologyProxy,
|
||||
synologyAuthFromQuery,
|
||||
getSynologyTargetUserId,
|
||||
streamSynologyAsset,
|
||||
handleSynologyError,
|
||||
SynologyServiceError,
|
||||
} from '../services/synologyService';
|
||||
|
||||
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, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
res.json(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) {
|
||||
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', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const albumId = parseStringBodyField(body.album_id);
|
||||
const albumName = parseStringBodyField(body.album_name);
|
||||
|
||||
if (!albumId) {
|
||||
return handleSynologyError(res, new SynologyServiceError(400, 'Album ID is required'), 'Missing required fields');
|
||||
}
|
||||
|
||||
try {
|
||||
linkSynologyAlbum(authReq.user.id, tripId, albumId, albumName || undefined);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
handleSynologyError(res, err, 'Failed to link album');
|
||||
}
|
||||
});
|
||||
|
||||
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/:photoId/info', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { photoId } = req.params;
|
||||
|
||||
try {
|
||||
res.json(await getSynologyAssetInfo(authReq.user.id, photoId, getSynologyTargetUserId(req)));
|
||||
} catch (err: unknown) {
|
||||
handleSynologyError(res, err, 'Could not reach Synology');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/assets/:photoId/thumbnail', synologyAuthFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { photoId } = req.params;
|
||||
const { size = 'sm' } = req.query;
|
||||
|
||||
try {
|
||||
const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'thumbnail', String(size));
|
||||
await pipeSynologyProxy(res, proxy);
|
||||
} catch (err: unknown) {
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
handleSynologyError(res, err, 'Proxy error');
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/assets/:photoId/original', synologyAuthFromQuery, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { photoId } = req.params;
|
||||
|
||||
try {
|
||||
const proxy = await streamSynologyAsset(authReq.user.id, getSynologyTargetUserId(req), photoId, 'original');
|
||||
await pipeSynologyProxy(res, proxy);
|
||||
} catch (err: unknown) {
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
handleSynologyError(res, err, 'Proxy error');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -465,17 +465,92 @@ export function deleteTemplateItem(itemId: string) {
|
||||
|
||||
export function listAddons() {
|
||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||
return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') }));
|
||||
const providers = db.prepare(`
|
||||
SELECT id, name, description, icon, enabled, config, sort_order
|
||||
FROM photo_providers
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{ id: string; name: string; description?: string | null; icon: string; enabled: number; config: string; sort_order: number }>;
|
||||
const fields = db.prepare(`
|
||||
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
ORDER BY sort_order, id
|
||||
`).all() as Array<{
|
||||
provider_id: string;
|
||||
field_key: string;
|
||||
label: string;
|
||||
input_type: string;
|
||||
placeholder?: string | null;
|
||||
required: number;
|
||||
secret: number;
|
||||
settings_key?: string | null;
|
||||
payload_key?: string | null;
|
||||
sort_order: number;
|
||||
}>;
|
||||
const fieldsByProvider = new Map<string, typeof fields>();
|
||||
for (const field of fields) {
|
||||
const arr = fieldsByProvider.get(field.provider_id) || [];
|
||||
arr.push(field);
|
||||
fieldsByProvider.set(field.provider_id, arr);
|
||||
}
|
||||
|
||||
return [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })),
|
||||
...providers.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
type: 'photo_provider',
|
||||
icon: p.icon,
|
||||
enabled: !!p.enabled,
|
||||
config: JSON.parse(p.config || '{}'),
|
||||
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
||||
key: f.field_key,
|
||||
label: f.label,
|
||||
input_type: f.input_type,
|
||||
placeholder: f.placeholder || '',
|
||||
required: !!f.required,
|
||||
secret: !!f.secret,
|
||||
settings_key: f.settings_key || null,
|
||||
payload_key: f.payload_key || null,
|
||||
sort_order: f.sort_order,
|
||||
})),
|
||||
sort_order: p.sort_order,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id);
|
||||
if (!addon) return { error: 'Addon not found', status: 404 };
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon;
|
||||
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;
|
||||
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
||||
|
||||
if (addon) {
|
||||
if (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
||||
if (data.config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(data.config), id);
|
||||
} else {
|
||||
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 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 updated = updatedAddon
|
||||
? { ...updatedAddon, enabled: !!updatedAddon.enabled, config: JSON.parse(updatedAddon.config || '{}') }
|
||||
: updatedProvider
|
||||
? {
|
||||
id: updatedProvider.id,
|
||||
name: updatedProvider.name,
|
||||
description: updatedProvider.description,
|
||||
type: 'photo_provider',
|
||||
icon: updatedProvider.icon,
|
||||
enabled: !!updatedProvider.enabled,
|
||||
config: JSON.parse(updatedProvider.config || '{}'),
|
||||
sort_order: updatedProvider.sort_order,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') },
|
||||
addon: updated,
|
||||
auditDetails: { enabled: data.enabled !== undefined ? !!data.enabled : undefined, config_changed: data.config !== undefined },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -981,7 +981,7 @@ export function createWsToken(userId: number): { error?: string; status?: number
|
||||
}
|
||||
|
||||
export function createResourceToken(userId: number, purpose?: string): { error?: string; status?: number; token?: string } {
|
||||
if (purpose !== 'download' && purpose !== 'immich') {
|
||||
if (purpose !== 'download' && purpose !== 'immich' && purpose !== 'synologyphotos') {
|
||||
return { error: 'Invalid purpose', status: 400 };
|
||||
}
|
||||
const token = createEphemeralToken(userId, purpose);
|
||||
|
||||
@@ -4,6 +4,7 @@ const TTL: Record<string, number> = {
|
||||
ws: 30_000,
|
||||
download: 60_000,
|
||||
immich: 60_000,
|
||||
synologyphotos: 60_000,
|
||||
};
|
||||
|
||||
const MAX_STORE_SIZE = 10_000;
|
||||
|
||||
@@ -175,11 +175,12 @@ export async function searchPhotos(
|
||||
|
||||
export function listTripPhotos(tripId: string, userId: number) {
|
||||
return db.prepare(`
|
||||
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||
SELECT tp.asset_id AS immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||
u.username, u.avatar, u.immich_url
|
||||
FROM trip_photos tp
|
||||
JOIN users u ON tp.user_id = u.id
|
||||
WHERE tp.trip_id = ?
|
||||
AND tp.provider = 'immich'
|
||||
AND (tp.user_id = ? OR tp.shared = 1)
|
||||
ORDER BY tp.added_at ASC
|
||||
`).all(tripId, userId);
|
||||
@@ -191,25 +192,23 @@ export function addTripPhotos(
|
||||
assetIds: string[],
|
||||
shared: boolean
|
||||
): number {
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)');
|
||||
let added = 0;
|
||||
for (const assetId of assetIds) {
|
||||
const result = insert.run(tripId, userId, assetId, shared ? 1 : 0);
|
||||
const result = insert.run(tripId, userId, assetId, 'immich', shared ? 1 : 0);
|
||||
if (result.changes > 0) added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
export function removeTripPhoto(tripId: string, userId: number, assetId: string) {
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(tripId, userId, assetId);
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?')
|
||||
.run(tripId, userId, assetId, 'immich');
|
||||
}
|
||||
|
||||
export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) {
|
||||
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(shared ? 1 : 0, tripId, userId, assetId);
|
||||
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, 'immich');
|
||||
}
|
||||
|
||||
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
||||
@@ -329,7 +328,7 @@ export function listAlbumLinks(tripId: string) {
|
||||
SELECT tal.*, u.username
|
||||
FROM trip_album_links tal
|
||||
JOIN users u ON tal.user_id = u.id
|
||||
WHERE tal.trip_id = ?
|
||||
WHERE tal.trip_id = ? AND tal.provider = 'immich'
|
||||
ORDER BY tal.created_at ASC
|
||||
`).all(tripId);
|
||||
}
|
||||
@@ -342,8 +341,8 @@ export function createAlbumLink(
|
||||
): { success: boolean; error?: string } {
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, userId, albumId, albumName || '');
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, 'immich', albumId, albumName || '');
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: 'Album already linked' };
|
||||
@@ -360,15 +359,15 @@ export async function syncAlbumAssets(
|
||||
linkId: string,
|
||||
userId: 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 = ?')
|
||||
.get(linkId, tripId, userId) as any;
|
||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?')
|
||||
.get(linkId, tripId, userId, 'immich') as any;
|
||||
if (!link) return { error: 'Album link not found', status: 404 };
|
||||
|
||||
const creds = getImmichCredentials(userId);
|
||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
|
||||
const resp = await fetch(`${creds.immich_url}/api/albums/${link.album_id}`, {
|
||||
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
@@ -376,9 +375,7 @@ export async function syncAlbumAssets(
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
|
||||
);
|
||||
const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)");
|
||||
let added = 0;
|
||||
for (const asset of assets) {
|
||||
const r = insert.run(tripId, userId, asset.id);
|
||||
|
||||
651
server/src/services/synologyService.ts
Normal file
651
server/src/services/synologyService.ts
Normal file
@@ -0,0 +1,651 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { NextFunction, Request, Response as ExpressResponse } from 'express';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key } from './apiKeyCrypto';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { AuthRequest } from '../types';
|
||||
import { consumeEphemeralToken } from './ephemeralTokens';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { no } from 'zod/locales';
|
||||
|
||||
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 synologyAuthFromQuery(req: Request, res: ExpressResponse, next: NextFunction) {
|
||||
const queryToken = req.query.token as string | undefined;
|
||||
if (queryToken) {
|
||||
const userId = consumeEphemeralToken(queryToken, SYNOLOGY_PROVIDER);
|
||||
if (!userId) return res.status(401).send('Invalid or expired token');
|
||||
const user = db.prepare('SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?').get(userId) as any;
|
||||
if (!user) return res.status(401).send('User not found');
|
||||
(req as AuthRequest).user = user;
|
||||
return next();
|
||||
}
|
||||
return (authenticate as any)(req, res, next);
|
||||
}
|
||||
|
||||
export function getSynologyTargetUserId(req: Request): number {
|
||||
const { userId } = req.query;
|
||||
return Number(userId);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function canStreamSynologyAsset(requestingUserId: number, targetUserId: number, assetId: string): boolean {
|
||||
if (requestingUserId === targetUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sharedAsset = db.prepare(`
|
||||
SELECT 1
|
||||
FROM trip_photos
|
||||
WHERE user_id = ?
|
||||
AND asset_id = ?
|
||||
AND provider = 'synologyphotos'
|
||||
AND shared = 1
|
||||
LIMIT 1
|
||||
`).get(targetUserId, assetId);
|
||||
|
||||
return !!sharedAsset;
|
||||
}
|
||||
|
||||
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 function linkSynologyAlbum(userId: number, tripId: string, albumId: string | number | undefined, albumName?: string): void {
|
||||
if (!canAccessTrip(tripId, userId)) {
|
||||
throw new SynologyServiceError(404, 'Trip not found');
|
||||
}
|
||||
|
||||
if (!albumId) {
|
||||
throw new SynologyServiceError(400, 'album_id required');
|
||||
}
|
||||
|
||||
const changes = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tripId, userId, SYNOLOGY_PROVIDER, String(albumId), albumName || '').changes;
|
||||
|
||||
if (changes === 0) {
|
||||
throw new SynologyServiceError(400, 'Album already linked');
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!canStreamSynologyAsset(userId, targetUserId ?? userId, photoId)) {
|
||||
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
||||
}
|
||||
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> {
|
||||
if (!canStreamSynologyAsset(userId, targetUserId, photoId)) {
|
||||
throw new SynologyServiceError(403, 'Youd don\'t have access to this photo');
|
||||
}
|
||||
|
||||
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