Merge pull request #2 from tiquis0290/test-backup
Resolving conflicts with dev
This commit is contained in:
@@ -147,7 +147,7 @@ services:
|
|||||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP
|
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||||
@@ -285,6 +285,7 @@ trek.yourdomain.com {
|
|||||||
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||||
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||||
| **OIDC / SSO** | | |
|
| **OIDC / SSO** | | |
|
||||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ data:
|
|||||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.APP_URL }}
|
||||||
|
APP_URL: {{ .Values.env.APP_URL | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ env:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
# ALLOWED_ORIGINS: ""
|
# ALLOWED_ORIGINS: ""
|
||||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||||
|
# APP_URL: "https://trek.example.com"
|
||||||
|
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||||
|
# Also used as the base URL for links in email notifications and other external links.
|
||||||
# ALLOW_INTERNAL_NETWORK: "false"
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export default function VacayMonthCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={di}
|
key={di}
|
||||||
|
title={holiday ? (holiday.label ? `${holiday.label}: ${holiday.localName}` : holiday.localName) : undefined}
|
||||||
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||||
style={{
|
style={{
|
||||||
height: 28,
|
height: 28,
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'شاركها {name}',
|
'dashboard.sharedBy': 'شاركها {name}',
|
||||||
'dashboard.days': 'الأيام',
|
'dashboard.days': 'الأيام',
|
||||||
'dashboard.places': 'الأماكن',
|
'dashboard.places': 'الأماكن',
|
||||||
|
'dashboard.members': 'ال חברים',
|
||||||
'dashboard.archive': 'أرشفة',
|
'dashboard.archive': 'أرشفة',
|
||||||
'dashboard.restore': 'استعادة',
|
'dashboard.restore': 'استعادة',
|
||||||
'dashboard.archived': 'مؤرشفة',
|
'dashboard.archived': 'مؤرشفة',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'Compartilhada por {name}',
|
'dashboard.sharedBy': 'Compartilhada por {name}',
|
||||||
'dashboard.days': 'Dias',
|
'dashboard.days': 'Dias',
|
||||||
'dashboard.places': 'Lugares',
|
'dashboard.places': 'Lugares',
|
||||||
|
'dashboard.members': 'Parceiros de viagem',
|
||||||
'dashboard.archive': 'Arquivar',
|
'dashboard.archive': 'Arquivar',
|
||||||
'dashboard.restore': 'Restaurar',
|
'dashboard.restore': 'Restaurar',
|
||||||
'dashboard.archived': 'Arquivada',
|
'dashboard.archived': 'Arquivada',
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'Sdílí {name}',
|
'dashboard.sharedBy': 'Sdílí {name}',
|
||||||
'dashboard.days': 'Dní',
|
'dashboard.days': 'Dní',
|
||||||
'dashboard.places': 'Míst',
|
'dashboard.places': 'Míst',
|
||||||
|
'dashboard.members': 'Cestovní parťáci',
|
||||||
'dashboard.archive': 'Archivovat',
|
'dashboard.archive': 'Archivovat',
|
||||||
'dashboard.restore': 'Obnovit',
|
'dashboard.restore': 'Obnovit',
|
||||||
'dashboard.archived': 'Archivováno',
|
'dashboard.archived': 'Archivováno',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'Geteilt von {name}',
|
'dashboard.sharedBy': 'Geteilt von {name}',
|
||||||
'dashboard.days': 'Tage',
|
'dashboard.days': 'Tage',
|
||||||
'dashboard.places': 'Orte',
|
'dashboard.places': 'Orte',
|
||||||
|
'dashboard.members': 'Reise-Buddies',
|
||||||
'dashboard.archive': 'Archivieren',
|
'dashboard.archive': 'Archivieren',
|
||||||
'dashboard.restore': 'Wiederherstellen',
|
'dashboard.restore': 'Wiederherstellen',
|
||||||
'dashboard.archived': 'Archiviert',
|
'dashboard.archived': 'Archiviert',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'Shared by {name}',
|
'dashboard.sharedBy': 'Shared by {name}',
|
||||||
'dashboard.days': 'Days',
|
'dashboard.days': 'Days',
|
||||||
'dashboard.places': 'Places',
|
'dashboard.places': 'Places',
|
||||||
|
'dashboard.members': 'Buddies',
|
||||||
'dashboard.archive': 'Archive',
|
'dashboard.archive': 'Archive',
|
||||||
'dashboard.restore': 'Restore',
|
'dashboard.restore': 'Restore',
|
||||||
'dashboard.archived': 'Archived',
|
'dashboard.archived': 'Archived',
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const es: Record<string, string> = {
|
|||||||
'dashboard.sharedBy': 'Compartido por {name}',
|
'dashboard.sharedBy': 'Compartido por {name}',
|
||||||
'dashboard.days': 'Días',
|
'dashboard.days': 'Días',
|
||||||
'dashboard.places': 'Lugares',
|
'dashboard.places': 'Lugares',
|
||||||
|
'dashboard.members': 'Compañeros de viaje',
|
||||||
'dashboard.archive': 'Archivar',
|
'dashboard.archive': 'Archivar',
|
||||||
'dashboard.restore': 'Restaurar',
|
'dashboard.restore': 'Restaurar',
|
||||||
'dashboard.archived': 'Archivado',
|
'dashboard.archived': 'Archivado',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const fr: Record<string, string> = {
|
|||||||
'dashboard.sharedBy': 'Partagé par {name}',
|
'dashboard.sharedBy': 'Partagé par {name}',
|
||||||
'dashboard.days': 'Jours',
|
'dashboard.days': 'Jours',
|
||||||
'dashboard.places': 'Lieux',
|
'dashboard.places': 'Lieux',
|
||||||
|
'dashboard.members': 'Compagnons de voyage',
|
||||||
'dashboard.archive': 'Archiver',
|
'dashboard.archive': 'Archiver',
|
||||||
'dashboard.restore': 'Restaurer',
|
'dashboard.restore': 'Restaurer',
|
||||||
'dashboard.archived': 'Archivé',
|
'dashboard.archived': 'Archivé',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'Megosztotta: {name}',
|
'dashboard.sharedBy': 'Megosztotta: {name}',
|
||||||
'dashboard.days': 'nap',
|
'dashboard.days': 'nap',
|
||||||
'dashboard.places': 'hely',
|
'dashboard.places': 'hely',
|
||||||
|
'dashboard.members': 'Útitársak',
|
||||||
'dashboard.archive': 'Archiválás',
|
'dashboard.archive': 'Archiválás',
|
||||||
'dashboard.restore': 'Visszaállítás',
|
'dashboard.restore': 'Visszaállítás',
|
||||||
'dashboard.archived': 'Archivált',
|
'dashboard.archived': 'Archivált',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dashboard.sharedBy': 'Condiviso da {name}',
|
'dashboard.sharedBy': 'Condiviso da {name}',
|
||||||
'dashboard.days': 'Giorni',
|
'dashboard.days': 'Giorni',
|
||||||
'dashboard.places': 'Luoghi',
|
'dashboard.places': 'Luoghi',
|
||||||
|
'dashboard.members': 'Compagni di viaggio',
|
||||||
'dashboard.archive': 'Archivia',
|
'dashboard.archive': 'Archivia',
|
||||||
'dashboard.restore': 'Ripristina',
|
'dashboard.restore': 'Ripristina',
|
||||||
'dashboard.archived': 'Archiviati',
|
'dashboard.archived': 'Archiviati',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const nl: Record<string, string> = {
|
|||||||
'dashboard.sharedBy': 'Gedeeld door {name}',
|
'dashboard.sharedBy': 'Gedeeld door {name}',
|
||||||
'dashboard.days': 'Dagen',
|
'dashboard.days': 'Dagen',
|
||||||
'dashboard.places': 'Plaatsen',
|
'dashboard.places': 'Plaatsen',
|
||||||
|
'dashboard.members': 'Reisgenoten',
|
||||||
'dashboard.archive': 'Archiveren',
|
'dashboard.archive': 'Archiveren',
|
||||||
'dashboard.restore': 'Herstellen',
|
'dashboard.restore': 'Herstellen',
|
||||||
'dashboard.archived': 'Gearchiveerd',
|
'dashboard.archived': 'Gearchiveerd',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const ru: Record<string, string> = {
|
|||||||
'dashboard.sharedBy': 'Поделился {name}',
|
'dashboard.sharedBy': 'Поделился {name}',
|
||||||
'dashboard.days': 'Дни',
|
'dashboard.days': 'Дни',
|
||||||
'dashboard.places': 'Места',
|
'dashboard.places': 'Места',
|
||||||
|
'dashboard.members': 'Попутчики',
|
||||||
'dashboard.archive': 'Архивировать',
|
'dashboard.archive': 'Архивировать',
|
||||||
'dashboard.restore': 'Восстановить',
|
'dashboard.restore': 'Восстановить',
|
||||||
'dashboard.archived': 'В архиве',
|
'dashboard.archived': 'В архиве',
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const zh: Record<string, string> = {
|
|||||||
'dashboard.sharedBy': '由 {name} 分享',
|
'dashboard.sharedBy': '由 {name} 分享',
|
||||||
'dashboard.days': '天',
|
'dashboard.days': '天',
|
||||||
'dashboard.places': '地点',
|
'dashboard.places': '地点',
|
||||||
|
'dashboard.members': '旅伴',
|
||||||
'dashboard.archive': '归档',
|
'dashboard.archive': '归档',
|
||||||
'dashboard.restore': '恢复',
|
'dashboard.restore': '恢复',
|
||||||
'dashboard.archived': '已归档',
|
'dashboard.archived': '已归档',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ConfirmDialog from '../components/shared/ConfirmDialog'
|
|||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import {
|
import {
|
||||||
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
|
||||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft, Users,
|
||||||
LayoutGrid, List,
|
LayoutGrid, List,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCanDo } from '../store/permissionsStore'
|
import { useCanDo } from '../store/permissionsStore'
|
||||||
@@ -31,6 +31,7 @@ interface DashboardTrip {
|
|||||||
owner_username?: string
|
owner_username?: string
|
||||||
day_count?: number
|
day_count?: number
|
||||||
place_count?: number
|
place_count?: number
|
||||||
|
shared_count?: number
|
||||||
[key: string]: string | number | boolean | null | undefined
|
[key: string]: string | number | boolean | null | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +225,9 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||||
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
|
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
|
||||||
|
<Users size={13} /> {trip.shared_count+1 || 0} {t('dashboard.members')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,6 +311,7 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omi
|
|||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||||
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
|
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
|
||||||
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
|
||||||
|
<Stat label={t('dashboard.members')} value={trip.shared_count+1 || 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(onEdit || onArchive || onDelete) && (
|
{(onEdit || onArchive || onDelete) && (
|
||||||
@@ -406,6 +411,9 @@ function TripListItem({ trip, onEdit, onDelete, onArchive, onClick, t, locale }:
|
|||||||
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
<MapPin size={11} /> {trip.place_count || 0}
|
<MapPin size={11} /> {trip.place_count || 0}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden md:flex" style={{ alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||||
|
<Users size={11} /> {trip.shared_count+1 || 0}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
setError('Invalid or expired invite link')
|
setError('Invalid or expired invite link')
|
||||||
})
|
})
|
||||||
window.history.replaceState({}, '', window.location.pathname)
|
window.history.replaceState({}, '', window.location.pathname)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oidcCode) {
|
if (oidcCode) {
|
||||||
@@ -87,7 +86,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
if (config) {
|
if (config) {
|
||||||
setAppConfig(config)
|
setAppConfig(config)
|
||||||
if (!config.has_users) setMode('register')
|
if (!config.has_users) setMode('register')
|
||||||
if (config.oidc_only_mode && config.oidc_configured && config.has_users) {
|
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite) {
|
||||||
window.location.href = '/api/auth/oidc/login'
|
window.location.href = '/api/auth/oidc/login'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||||
- TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP)
|
- TRUST_PROXY=1 # Number of trusted proxies (for X-Forwarded-For / real client IP)
|
||||||
- ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
- ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||||
|
# - APP_URL=https://trek.example.com # Public base URL — required when OIDC is enabled (must match the redirect URI registered with your IdP); also used as base URL for links in email notifications
|
||||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||||
|
|||||||
@@ -379,12 +379,9 @@ router.post('/rotate-jwt-secret', (req: Request, res: Response) => {
|
|||||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
writeAudit({
|
writeAudit({
|
||||||
user_id: authReq.user?.id ?? null,
|
userId: authReq.user?.id ?? null,
|
||||||
username: authReq.user?.username ?? 'unknown',
|
|
||||||
action: 'admin.rotate_jwt_secret',
|
action: 'admin.rotate_jwt_secret',
|
||||||
target_type: 'system',
|
resource: 'system',
|
||||||
target_id: null,
|
|
||||||
details: null,
|
|
||||||
ip: getClientIp(req),
|
ip: getClientIp(req),
|
||||||
});
|
});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
|
// ── Dual auth middleware (JWT or ephemeral token for <img> src) ─────────────
|
||||||
|
|
||||||
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
function authFromQuery(req: Request, res: Response, next: NextFunction) {
|
||||||
const queryToken = req.query.token as string | undefined;
|
const queryToken = req.query.token as string | undefined;
|
||||||
if (queryToken) {
|
if (queryToken) {
|
||||||
@@ -178,7 +177,6 @@ router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
|||||||
res.status(502).json({ error: 'Could not reach Immich' });
|
res.status(502).json({ error: 'Could not reach Immich' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
const authReq = req as AuthRequest;
|
||||||
const { tripId } = req.params;
|
const { tripId } = req.params;
|
||||||
|
|||||||
@@ -465,17 +465,92 @@ export function deleteTemplateItem(itemId: string) {
|
|||||||
|
|
||||||
export function listAddons() {
|
export function listAddons() {
|
||||||
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
||||||
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> }) {
|
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
||||||
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id);
|
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
||||||
if (!addon) return { error: 'Addon not found', status: 404 };
|
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 (data.enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
||||||
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;
|
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 {
|
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 },
|
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 } {
|
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 };
|
return { error: 'Invalid purpose', status: 400 };
|
||||||
}
|
}
|
||||||
const token = createEphemeralToken(userId, purpose);
|
const token = createEphemeralToken(userId, purpose);
|
||||||
|
|||||||
@@ -175,11 +175,12 @@ export async function searchPhotos(
|
|||||||
|
|
||||||
export function listTripPhotos(tripId: string, userId: number) {
|
export function listTripPhotos(tripId: string, userId: number) {
|
||||||
return db.prepare(`
|
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
|
u.username, u.avatar, u.immich_url
|
||||||
FROM trip_photos tp
|
FROM trip_photos tp
|
||||||
JOIN users u ON tp.user_id = u.id
|
JOIN users u ON tp.user_id = u.id
|
||||||
WHERE tp.trip_id = ?
|
WHERE tp.trip_id = ?
|
||||||
|
AND tp.provider = 'immich'
|
||||||
AND (tp.user_id = ? OR tp.shared = 1)
|
AND (tp.user_id = ? OR tp.shared = 1)
|
||||||
ORDER BY tp.added_at ASC
|
ORDER BY tp.added_at ASC
|
||||||
`).all(tripId, userId);
|
`).all(tripId, userId);
|
||||||
@@ -191,25 +192,23 @@ export function addTripPhotos(
|
|||||||
assetIds: string[],
|
assetIds: string[],
|
||||||
shared: boolean
|
shared: boolean
|
||||||
): number {
|
): number {
|
||||||
const insert = db.prepare(
|
const insert = db.prepare('INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, ?, ?)');
|
||||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)'
|
|
||||||
);
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const assetId of assetIds) {
|
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++;
|
if (result.changes > 0) added++;
|
||||||
}
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTripPhoto(tripId: string, userId: number, assetId: string) {
|
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 = ?')
|
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND asset_id = ? AND provider = ?')
|
||||||
.run(tripId, userId, assetId);
|
.run(tripId, userId, assetId, 'immich');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePhotoSharing(tripId: string, userId: number, assetId: string, shared: boolean) {
|
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 = ?')
|
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);
|
.run(shared ? 1 : 0, tripId, userId, assetId, 'immich');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
// ── Asset Info / Proxy ─────────────────────────────────────────────────────
|
||||||
@@ -329,7 +328,7 @@ export function listAlbumLinks(tripId: string) {
|
|||||||
SELECT tal.*, u.username
|
SELECT tal.*, u.username
|
||||||
FROM trip_album_links tal
|
FROM trip_album_links tal
|
||||||
JOIN users u ON tal.user_id = u.id
|
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
|
ORDER BY tal.created_at ASC
|
||||||
`).all(tripId);
|
`).all(tripId);
|
||||||
}
|
}
|
||||||
@@ -342,8 +341,8 @@ export function createAlbumLink(
|
|||||||
): { success: boolean; error?: string } {
|
): { success: boolean; error?: string } {
|
||||||
try {
|
try {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
|
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
|
||||||
).run(tripId, userId, albumId, albumName || '');
|
).run(tripId, userId, 'immich', albumId, albumName || '');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: 'Album already linked' };
|
return { success: false, error: 'Album already linked' };
|
||||||
@@ -360,15 +359,15 @@ export async function syncAlbumAssets(
|
|||||||
linkId: string,
|
linkId: string,
|
||||||
userId: number
|
userId: number
|
||||||
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
|
): Promise<{ success?: boolean; added?: number; total?: number; error?: string; status?: number }> {
|
||||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ? AND provider = ?')
|
||||||
.get(linkId, tripId, userId) as any;
|
.get(linkId, tripId, userId, 'immich') as any;
|
||||||
if (!link) return { error: 'Album link not found', status: 404 };
|
if (!link) return { error: 'Album link not found', status: 404 };
|
||||||
|
|
||||||
const creds = getImmichCredentials(userId);
|
const creds = getImmichCredentials(userId);
|
||||||
if (!creds) return { error: 'Immich not configured', status: 400 };
|
if (!creds) return { error: 'Immich not configured', status: 400 };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${creds.immich_url}/api/albums/${link.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' },
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
@@ -376,9 +375,7 @@ export async function syncAlbumAssets(
|
|||||||
const albumData = await resp.json() as { assets?: any[] };
|
const albumData = await resp.json() as { assets?: any[] };
|
||||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||||
|
|
||||||
const insert = db.prepare(
|
const insert = db.prepare("INSERT OR IGNORE INTO trip_photos (trip_id, user_id, asset_id, provider, shared) VALUES (?, ?, ?, 'immich', 1)");
|
||||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
|
|
||||||
);
|
|
||||||
let added = 0;
|
let added = 0;
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const r = insert.run(tripId, userId, asset.id);
|
const r = insert.run(tripId, userId, asset.id);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function getWebhookUrl(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getAppUrl(): string {
|
function getAppUrl(): string {
|
||||||
|
if (process.env.APP_URL) return process.env.APP_URL;
|
||||||
const origins = process.env.ALLOWED_ORIGINS;
|
const origins = process.env.ALLOWED_ORIGINS;
|
||||||
if (origins) {
|
if (origins) {
|
||||||
const first = origins.split(',')[0]?.trim();
|
const first = origins.split(',')[0]?.trim();
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
|
|
||||||
// Collab messages (only if owner chose to share)
|
// Collab messages (only if owner chose to share)
|
||||||
const collabMessages = permissions.share_collab
|
const collabMessages = permissions.share_collab
|
||||||
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
|
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY m.created_at').all(tripId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user