Merge branch 'dev' into feature/naver-support

This commit is contained in:
Marco Sadowski
2026-04-13 10:04:28 +02:00
committed by GitHub
220 changed files with 34926 additions and 3272 deletions
+116 -23
View File
@@ -7,10 +7,11 @@ import { User, Addon } from '../types';
import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions } from '../mcp';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { validatePassword } from './passwordPolicy';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
import { resolveAuthToggles } from './authService';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -20,13 +21,26 @@ export function utcSuffix(ts: string | null | undefined): string | null {
}
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0, nb = pb[i] || 0;
const parse = (v: string) => {
const [base, pre] = v.split('-pre.');
const parts = base.split('.').map(Number);
const n = pre !== undefined ? parseInt(pre, 10) : null;
const preN = n !== null && Number.isFinite(n) ? n : null;
return { parts, preN };
};
const pa = parse(a), pb = parse(b);
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
const na = pa.parts[i] || 0, nb = pb.parts[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
// Equal base: stable > prerelease; higher preN wins among prereleases
if (pa.preN === null && pb.preN !== null) return 1;
if (pa.preN !== null && pb.preN === null) return -1;
if (pa.preN !== null && pb.preN !== null) {
if (pa.preN > pb.preN) return 1;
if (pa.preN < pb.preN) return -1;
}
return 0;
}
@@ -254,16 +268,20 @@ export function updateOidcSettings(data: {
client_id?: string;
client_secret?: string;
display_name?: string;
oidc_only?: boolean;
discovery_url?: string;
}) {
}): { error?: string; status?: number; success?: boolean } {
// Lockout prevention: can't remove OIDC config when password login is disabled
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
}
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', data.issuer ?? '');
set('oidc_client_id', data.client_id ?? '');
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
set('oidc_display_name', data.display_name ?? '');
set('oidc_only', data.oidc_only ? 'true' : 'false');
set('oidc_discovery_url', data.discovery_url ?? '');
return { success: true };
}
// ── Demo Baseline ──────────────────────────────────────────────────────────
@@ -298,21 +316,72 @@ export async function getGithubReleases(perPage: string = '10', page: string = '
}
}
export async function checkVersion() {
const { version: currentVersion } = require('../../package.json');
try {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) return { current: currentVersion, latest: currentVersion, update_available: false };
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
return { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker };
} catch {
return { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker };
interface VersionInfo {
current: string;
latest: string;
update_available: boolean;
release_url?: string;
is_docker: boolean;
is_prerelease: boolean;
}
const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let _versionCache: { data: VersionInfo; expiresAt: number } | null = null;
/** Test-only: clear the in-memory version cache. */
export function __clearVersionCacheForTests(): void {
_versionCache = null;
}
export async function checkVersion(): Promise<VersionInfo> {
if (_versionCache && Date.now() < _versionCache.expiresAt) {
return _versionCache.data;
}
const currentVersion: string = process.env.APP_VERSION || require('../../package.json').version;
const isPrerelease = currentVersion.includes('-pre.');
const fallback: VersionInfo = { current: currentVersion, latest: currentVersion, update_available: false, is_docker: isDocker, is_prerelease: isPrerelease };
let result: VersionInfo;
try {
if (isPrerelease) {
// Fetch release list and find the newest prerelease
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases?per_page=100',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
const prereleases = Array.isArray(data) ? data.filter(r => r.prerelease) : [];
if (!prereleases.length) {
return fallback;
}
// Pre-compute stripped versions, then sort descending
const tagged = prereleases.map(r => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
tagged.sort((a, b) => compareVersions(b.v, a.v));
const latest = tagged[0].v;
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: tagged[0].r.html_url || '', is_docker: isDocker, is_prerelease: true };
} else {
const resp = await fetch(
'https://api.github.com/repos/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
if (!resp.ok) {
return fallback;
}
const data = await resp.json() as { tag_name?: string; html_url?: string };
const latest = (data.tag_name || '').replace(/^v/, '');
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
result = { current: currentVersion, latest, update_available, release_url: data.html_url || '', is_docker: isDocker, is_prerelease: false };
}
} catch {
return fallback;
}
_versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL };
return result;
}
export async function checkAndNotifyVersion(): Promise<void> {
@@ -330,7 +399,7 @@ export async function checkAndNotifyVersion(): Promise<void> {
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: result.latest as string },
params: { version: result.latest },
});
} catch {
// Silently ignore — version check is non-critical
@@ -603,6 +672,30 @@ export function deleteMcpToken(id: string) {
return {};
}
// ── OAuth Sessions ─────────────────────────────────────────────────────────
export function listOAuthSessions() {
const rows = db.prepare(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
JOIN users u ON u.id = ot.user_id
WHERE ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all() as (Record<string, unknown> & { scopes: string })[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes) }));
}
export function revokeOAuthSession(id: string) {
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as { id: number; user_id: number; client_id: string } | undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
revokeUserSessionsForClient(row.user_id, row.client_id);
return {};
}
// ── JWT Rotation ───────────────────────────────────────────────────────────
export function rotateJwtSecret(): { error?: string; status?: number } {
+69 -18
View File
@@ -71,18 +71,35 @@ setInterval(() => {
export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
AF:[60.5,29.4,75,38.5],AL:[19,39.6,21.1,42.7],DZ:[-8.7,19,12,37.1],AD:[1.4,42.4,1.8,42.7],AO:[11.7,-18.1,24.1,-4.4],
AR:[-73.6,-55.1,-53.6,-21.8],AM:[43.4,38.8,46.6,41.3],AU:[112.9,-43.6,153.6,-10.7],AT:[9.5,46.4,17.2,49],AZ:[44.8,38.4,50.4,41.9],
BD:[88.0,20.7,92.7,26.6],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],BG:[22.4,41.2,28.6,44.2],CA:[-141,41.7,-52.6,83.1],CL:[-75.6,-55.9,-66.9,-17.5],
CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],HR:[13.5,42.4,19.5,46.6],CZ:[12.1,48.6,18.9,51.1],DK:[8,54.6,15.2,57.8],
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],FI:[20.6,59.8,31.6,70.1],FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],
GR:[19.4,34.8,29.7,41.8],HU:[16,45.7,22.9,48.6],IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],
IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KR:[126,33.2,129.6,38.6],LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],
LU:[5.7,49.4,6.5,50.2],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MA:[-13.2,27.7,-1,35.9],NL:[3.4,50.8,7.2,53.5],
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],
PL:[14.1,49,24.1,54.9],PT:[-9.5,36.8,-6.2,42.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],SA:[34.6,16.4,55.7,32.2],
RS:[18.8,42.2,23,46.2],SK:[16.8,47.7,22.6,49.6],SI:[13.4,45.4,16.6,46.9],ZA:[16.5,-34.8,32.9,-22.1],ES:[-9.4,36,-0.2,43.8],
BA:[15.7,42.6,19.6,45.3],BD:[88.0,20.7,92.7,26.6],BF:[-5.5,9.4,2.4,15.1],BH:[50.4,25.8,50.7,26.2],BI:[29.0,-4.5,30.8,-2.3],
BJ:[0.8,6.2,3.8,12.4],BN:[114.1,4.0,115.4,5.1],BO:[-69.7,-22.9,-57.5,-9.7],BR:[-73.9,-33.8,-34.8,5.3],BE:[2.5,49.5,6.4,51.5],
BG:[22.4,41.2,28.6,44.2],BW:[20.0,-26.9,29.4,-17.8],CA:[-141,41.7,-52.6,83.1],CD:[12.2,-13.5,31.3,5.4],CG:[11.2,-5.0,18.7,3.7],
CI:[-8.6,4.3,-2.5,10.7],CL:[-75.6,-55.9,-66.9,-17.5],CM:[8.4,1.7,16.2,13.1],CN:[73.6,18.2,134.8,53.6],CO:[-79.1,-4.3,-66.9,12.5],
CR:[-85.9,8.0,-82.5,11.2],CU:[-85.0,19.8,-74.1,23.2],CV:[-25.4,14.8,-22.7,17.2],CY:[32.3,34.5,34.1,35.7],HR:[13.5,42.4,19.5,46.6],
CZ:[12.1,48.6,18.9,51.1],DJ:[41.8,11.0,43.4,12.7],DK:[8,54.6,15.2,57.8],DO:[-72.0,17.5,-68.3,19.9],EC:[-81.0,-5.0,-75.2,1.5],
EG:[24.7,22,37,31.7],EE:[21.8,57.5,28.2,59.7],ER:[36.4,12.4,43.1,18.0],ET:[33.0,3.4,47.9,14.9],FI:[20.6,59.8,31.6,70.1],
FR:[-5.1,41.3,9.6,51.1],DE:[5.9,47.3,15.1,55.1],GE:[40.0,41.0,46.7,43.6],GH:[-3.3,4.7,1.2,11.2],GN:[-15.1,7.2,-7.6,12.7],
GR:[19.4,34.8,29.7,41.8],GT:[-92.2,13.7,-88.2,17.8],HN:[-89.4,12.9,-83.2,16.5],HT:[-74.5,18.0,-71.6,20.1],HU:[16,45.7,22.9,48.6],
IS:[-24.5,63.4,-13.5,66.6],IN:[68.2,6.7,97.4,35.5],ID:[95.3,-11,141,5.9],IR:[44.1,25.1,63.3,39.8],IQ:[38.8,29.1,48.6,37.4],
IE:[-10.5,51.4,-6,55.4],IL:[34.3,29.5,35.9,33.3],IT:[6.6,36.6,18.5,47.1],JM:[-78.4,17.7,-76.2,18.5],JO:[34.9,29.2,39.3,33.4],
JP:[129.4,31.1,145.5,45.5],KE:[33.9,-4.7,41.9,5.5],KG:[69.2,39.2,80.3,43.2],KH:[102.3,10.4,107.6,14.7],KR:[126,33.2,129.6,38.6],
KW:[46.5,28.5,48.4,30.1],KZ:[50.3,40.6,87.4,55.4],LA:[100.1,13.9,107.7,22.5],LB:[35.1,33.1,36.6,34.7],LK:[79.7,5.9,81.9,9.8],
LV:[21,55.7,28.2,58.1],LT:[21,53.9,26.8,56.5],LU:[5.7,49.4,6.5,50.2],LY:[9.5,19.5,25.2,33.3],MA:[-13.2,27.7,-1,35.9],
MD:[26.6,45.5,30.2,48.5],ME:[18.4,41.8,20.4,43.6],MG:[43.2,-25.6,50.5,-11.9],MK:[20.5,40.8,23.0,42.4],ML:[-4.8,10.1,4.3,25.0],
MM:[92.2,9.8,101.2,28.5],MN:[87.8,41.6,119.9,52.1],MR:[-17.1,14.7,-4.8,27.3],MT:[14.1,35.8,14.6,36.1],MU:[57.3,-20.5,57.8,-19.9],
MV:[72.7,-0.7,73.8,7.1],MW:[32.7,-17.1,35.9,-9.4],MY:[99.6,0.9,119.3,7.4],MX:[-118.4,14.5,-86.7,32.7],MZ:[30.2,-26.9,40.8,-10.5],
NA:[11.7,-28.9,25.3,-17.0],NE:[0.2,11.7,15.9,23.5],NI:[-87.7,10.7,-83.1,15.0],NL:[3.4,50.8,7.2,53.5],NP:[80.1,26.4,88.2,30.4],
NZ:[166.4,-47.3,178.5,-34.4],NO:[4.6,58,31.1,71.2],OM:[51.9,16.6,59.8,26.4],PA:[-83.0,7.2,-77.2,9.6],PG:[140.8,-11.7,155.7,-1.3],
PK:[60.9,23.7,77.1,37.1],PE:[-81.3,-18.4,-68.7,-0.1],PH:[117,5,126.6,18.5],PL:[14.1,49,24.1,54.9],PS:[34.2,29.5,35.6,32.6],
PT:[-9.5,36.8,-6.2,42.2],PY:[-62.6,-27.6,-54.3,-19.3],QA:[50.7,24.5,51.6,26.2],RO:[20.2,43.6,29.7,48.3],RU:[19.6,41.2,180,81.9],
RW:[29.0,-2.8,30.9,-1.0],SA:[34.6,16.4,55.7,32.2],SC:[55.3,-9.7,55.8,-3.7],SD:[21.8,3.4,38.6,22.2],SG:[103.6,1.2,104.1,1.5],
SI:[13.4,45.4,16.6,46.9],SK:[16.8,47.7,22.6,49.6],SN:[-17.5,12.3,-11.4,15.0],SO:[40.9,-1.7,51.4,11.9],RS:[18.8,42.2,23,46.2],
SV:[-90.1,13.2,-87.7,14.5],SY:[35.7,32.3,42.4,37.3],TG:[-0.2,6.1,1.8,11.2],TJ:[67.3,36.7,75.2,41.0],TM:[52.4,35.1,66.7,42.8],
TN:[7.5,30.2,11.6,37.5],TT:[-61.9,10.0,-60.5,11.3],TW:[120.1,21.9,122.0,25.3],TZ:[29.3,-11.7,40.4,-1.0],ZA:[16.5,-34.8,32.9,-22.1],
SE:[11.1,55.3,24.2,69.1],CH:[6,45.8,10.5,47.8],TH:[97.3,5.6,105.6,20.5],TR:[26,36,44.8,42.1],UA:[22.1,44.4,40.2,52.4],
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
};
export const NAME_TO_CODE: Record<string, string> = {
@@ -106,16 +123,50 @@ export const NAME_TO_CODE: Record<string, string> = {
'malaysia':'MY','colombia':'CO','kolumbien':'CO','peru':'PE','chile':'CL','iran':'IR',
'iraq':'IQ','irak':'IQ','pakistan':'PK','kenya':'KE','kenia':'KE','nigeria':'NG',
'saudi arabia':'SA','saudi-arabien':'SA','albania':'AL','albanien':'AL',
'georgia':'GE','georgien':'GE','montenegro':'ME','north macedonia':'MK','nordmazedonien':'MK',
'macedonia':'MK','bosnia':'BA','bosnia and herzegovina':'BA','bosnien':'BA','kosovo':'XK',
'cyprus':'CY','zypern':'CY','malta':'MT','tunisia':'TN','tunesien':'TN','jordan':'JO','jordanien':'JO',
'lebanon':'LB','libanon':'LB','ghana':'GH','ethiopia':'ET','athiopien':'ET','tanzania':'TZ','uganda':'UG',
'singapore':'SG','taiwan':'TW','nepal':'NP','sri lanka':'LK','cambodia':'KH','kambodscha':'KH',
'myanmar':'MM','burma':'MM','laos':'LA','mongolia':'MN','mongolei':'MN','kazakhstan':'KZ','kasachstan':'KZ',
'uzbekistan':'UZ','usbekistan':'UZ','kyrgyzstan':'KG','kirgisistan':'KG','tajikistan':'TJ','tadschikistan':'TJ',
'turkmenistan':'TM','costa rica':'CR','panama':'PA','ecuador':'EC','uruguay':'UY','cuba':'CU','kuba':'CU',
'dominican republic':'DO','dominikanische republik':'DO','jamaica':'JM','haiti':'HT','honduras':'HN',
'guatemala':'GT','el salvador':'SV','nicaragua':'NI','bolivia':'BO','bolivia plurinational state of':'BO',
'paraguay':'PY','venezuela':'VE','trinidad and tobago':'TT','trinidad':'TT',
'oman':'OM','kuwait':'KW','qatar':'QA','bahrain':'BH',
'syria':'SY','syrien':'SY','yemen':'YE','jemen':'YE','palestine':'PS','palastina':'PS',
'moldova':'MD','republic of moldova':'MD','moldawien':'MD',
'libya':'LY','libyen':'LY','sudan':'SD','eritrea':'ER','djibouti':'DJ',
'senegal':'SN','cameroon':'CM','kamerun':'CM','ivory coast':'CI','cote d\'ivoire':'CI',
'mali':'ML','niger':'NE','burkina faso':'BF','togo':'TG','benin':'BJ','guinea':'GN',
'dr congo':'CD','democratic republic of the congo':'CD','republic of the congo':'CG','congo':'CG',
'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW',
'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI',
'somalia':'SO','papua new guinea':'PG','brunei':'BN',
};
export const CONTINENT_MAP: Record<string, string> = {
AF:'Africa',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BR:'South America',BE:'Europe',BG:'Europe',CA:'North America',CL:'South America',CN:'Asia',CO:'South America',HR:'Europe',CZ:'Europe',DK:'Europe',
EG:'Africa',EE:'Europe',FI:'Europe',FR:'Europe',DE:'Europe',GR:'Europe',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',
IR:'Asia',IQ:'Asia',IE:'Europe',IL:'Asia',IT:'Europe',JP:'Asia',KE:'Africa',KR:'Asia',LV:'Europe',LT:'Europe',
LU:'Europe',MY:'Asia',MX:'North America',MA:'Africa',NL:'Europe',NZ:'Oceania',NO:'Europe',PK:'Asia',PE:'South America',PH:'Asia',
PL:'Europe',PT:'Europe',RO:'Europe',RU:'Europe',SA:'Asia',RS:'Europe',SK:'Europe',SI:'Europe',ZA:'Africa',ES:'Europe',
SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',NG:'Africa',
AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America',
BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa',
CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America',
CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe',
DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa',
FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America',
HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia',
IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia',
KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa',
MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa',
MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa',
NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia',
PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia',
PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa',
SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America',
SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa',
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
};
// ── Geocoding helpers ───────────────────────────────────────────────────────
+70 -19
View File
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
const avatarDir = path.join(__dirname, '../../uploads/avatars');
@@ -107,16 +108,51 @@ export function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
export function isOidcOnlyMode(): boolean {
export function resolveAuthToggles(): {
password_login: boolean;
password_registration: boolean;
oidc_login: boolean;
oidc_registration: boolean;
} {
const get = (key: string) =>
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
if (!enabled) return false;
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
.some(k => get(k) !== null);
if (hasNewKeys) {
const result = {
password_login: get('password_login') !== 'false',
password_registration: get('password_registration') !== 'false',
oidc_login: get('oidc_login') !== 'false',
oidc_registration: get('oidc_registration') !== 'false',
};
if (process.env.OIDC_ONLY === 'true') {
result.password_login = false;
result.password_registration = false;
}
return result;
}
// Legacy fallback
const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
);
return oidcConfigured;
const oidcOnly = oidcOnlyEnabled && oidcConfigured;
const allowReg = (get('allow_registration') ?? 'true') === 'true';
return {
password_login: !oidcOnly,
password_registration: !oidcOnly && allowReg,
oidc_login: true,
oidc_registration: allowReg,
};
}
export function isOidcOnlyMode(): boolean {
return !resolveAuthToggles().password_login;
}
export function generateToken(user: { id: number | bigint }) {
@@ -174,10 +210,9 @@ export function getPendingMfaSecret(userId: number): string | null {
export function getAppConfig(authenticatedUser: { id: number } | null) {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
const isDemo = process.env.DEMO_MODE === 'true';
const { version } = require('../../package.json');
const toggles = resolveAuthToggles();
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_display_name'").get() as { value: string } | undefined)?.value || null;
@@ -185,9 +220,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
const oidcOnlySetting = process.env.OIDC_ONLY ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
@@ -200,14 +232,22 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
return {
allow_registration: isDemo ? false : allowRegistration,
// Legacy fields (backward compat)
allow_registration: isDemo ? false : (toggles.password_registration || toggles.oidc_registration),
oidc_only_mode: !toggles.password_login && !toggles.password_registration,
// Granular toggles
password_login: toggles.password_login,
password_registration: isDemo ? false : toggles.password_registration,
oidc_login: toggles.oidc_login,
oidc_registration: isDemo ? false : toggles.oidc_registration,
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
has_users: userCount > 0,
setup_complete: setupComplete,
version,
is_prerelease: version.includes('-pre.'),
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
@@ -265,12 +305,9 @@ export function registerUser(body: {
}
if (userCount > 0 && !validInvite) {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
}
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return { error: 'Registration is disabled. Contact your administrator.', status: 403 };
const toggles = resolveAuthToggles();
if (!toggles.password_registration) {
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
}
}
@@ -707,6 +744,20 @@ export function updateAppSettings(
}
}
// Lockout prevention: can't disable all login methods
if (body.password_login !== undefined || body.oidc_login !== undefined) {
const current = resolveAuthToggles();
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
}
}
for (const key of ADMIN_SETTINGS_KEYS) {
if (body[key] !== undefined) {
let val = String(body[key]);
+747
View File
@@ -0,0 +1,747 @@
import { db } from '../db/database';
import { broadcastToUser } from '../websocket';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types';
function ts(): number {
return Date.now();
}
function broadcastJourneyEvent(journeyId: number, event: string, data: Record<string, unknown>, excludeUserId?: number) {
const contributors = db.prepare(
'SELECT user_id FROM journey_contributors WHERE journey_id = ?'
).all(journeyId) as { user_id: number }[];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number } | undefined;
const userIds = new Set(contributors.map(c => c.user_id));
if (owner) userIds.add(owner.user_id);
for (const uid of userIds) {
if (uid === excludeUserId) continue;
broadcastToUser(uid, { type: event, journeyId, ...data });
}
}
// ── Access control ───────────────────────────────────────────────────────
export function canAccessJourney(journeyId: number, userId: number): Journey | null {
const own = db.prepare('SELECT * FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId) as Journey | undefined;
if (own) return own;
const contrib = db.prepare(
'SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journeyId, userId);
if (contrib) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey || null;
return null;
}
export function isOwner(journeyId: number, userId: number): boolean {
return !!db.prepare('SELECT 1 FROM journeys WHERE id = ? AND user_id = ?').get(journeyId, userId);
}
export function canEdit(journeyId: number, userId: number): boolean {
if (isOwner(journeyId, userId)) return true;
const c = db.prepare(
"SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?"
).get(journeyId, userId) as { role: string } | undefined;
return c?.role === 'editor' || c?.role === 'owner';
}
// ── Journey CRUD ─────────────────────────────────────────────────────────
export function listJourneys(userId: number) {
return db.prepare(`
SELECT DISTINCT j.*,
(SELECT COUNT(*) FROM journey_entries je WHERE je.journey_id = j.id AND je.type != 'skeleton') as entry_count,
(SELECT COUNT(*) FROM journey_photos jp JOIN journey_entries je2 ON jp.entry_id = je2.id WHERE je2.journey_id = j.id) as photo_count,
(SELECT COUNT(DISTINCT je3.location_name) FROM journey_entries je3 WHERE je3.journey_id = j.id AND je3.location_name IS NOT NULL AND je3.location_name != '') as city_count
FROM journeys j
LEFT JOIN journey_contributors jc ON j.id = jc.journey_id AND jc.user_id = ?
WHERE j.user_id = ? OR jc.user_id = ?
ORDER BY j.updated_at DESC
`).all(userId, userId, userId) as (Journey & { entry_count: number; photo_count: number; city_count: number })[];
}
export function createJourney(userId: number, data: {
title: string;
subtitle?: string;
trip_ids?: number[];
}): Journey {
const now = ts();
const res = db.prepare(`
INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?)
`).run(userId, data.title, data.subtitle || null, now, now);
const journeyId = Number(res.lastInsertRowid);
// add owner as contributor
db.prepare(
'INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
).run(journeyId, userId, 'owner', now);
// link trips and sync skeleton entries
if (data.trip_ids?.length) {
for (const tripId of data.trip_ids) {
addTripToJourney(journeyId, tripId, userId);
}
// inherit cover image from first selected trip
const firstTrip = db.prepare('SELECT cover_image FROM trips WHERE id = ?').get(data.trip_ids[0]) as { cover_image: string | null } | undefined;
if (firstTrip?.cover_image) {
// trip stores full path (/uploads/covers/x.jpg), journey stores relative (covers/x.jpg)
const relativePath = firstTrip.cover_image.replace(/^\/uploads\//, '');
db.prepare('UPDATE journeys SET cover_image = ? WHERE id = ?').run(relativePath, journeyId);
}
}
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
export function getJourneyFull(journeyId: number, userId: number) {
const journey = canAccessJourney(journeyId, userId);
if (!journey) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
).all(journeyId) as JourneyPhoto[];
// group photos by entry
const photosByEntry: Record<number, JourneyPhoto[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
source_trip_name: e.source_trip_id
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
: null,
}));
// linked trips
const trips = db.prepare(`
SELECT jt.trip_id, jt.added_at, t.title, t.start_date, t.end_date, t.cover_image, t.currency,
(SELECT COUNT(*) FROM places WHERE trip_id = t.id) as place_count
FROM journey_trips jt JOIN trips t ON jt.trip_id = t.id
WHERE jt.journey_id = ? ORDER BY t.start_date ASC
`).all(journeyId);
// contributors
const contributorsRaw = db.prepare(`
SELECT jc.journey_id, jc.user_id, jc.role, jc.added_at, u.username, u.avatar
FROM journey_contributors jc JOIN users u ON jc.user_id = u.id
WHERE jc.journey_id = ? ORDER BY jc.added_at
`).all(journeyId) as any[];
const contributors = contributorsRaw.map(c => ({
...c,
avatar_url: c.avatar ? `/uploads/avatars/${c.avatar}` : null,
}));
// stats
const entryCount = entries.filter(e => e.type === 'entry').length;
const photoCount = photos.length;
const cities = [...new Set(entries.map(e => e.location_name).filter(Boolean))];
return {
...journey,
entries: enrichedEntries,
trips,
contributors,
stats: { entries: entryCount, photos: photoCount, cities: cities.length },
};
}
export function updateJourney(journeyId: number, userId: number, data: Partial<{
title: string;
subtitle: string;
cover_gradient: string;
cover_image: string;
status: string;
}>): Journey | null {
if (!canEdit(journeyId, userId)) return null;
const allowed = ['title', 'subtitle', 'cover_gradient', 'cover_image', 'status'];
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && allowed.includes(key)) {
fields.push(`${key} = ?`);
values.push(val);
}
}
if (fields.length === 0) return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
fields.push('updated_at = ?');
values.push(ts());
values.push(journeyId);
db.prepare(`UPDATE journeys SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey;
}
export function deleteJourney(journeyId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare('DELETE FROM journeys WHERE id = ?').run(journeyId);
return true;
}
// ── Trip management ──────────────────────────────────────────────────────
export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean {
const now = ts();
try {
db.prepare(
'INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)'
).run(journeyId, tripId, now);
} catch { return false; }
// sync skeleton entries for all places in this trip
syncTripPlaces(journeyId, tripId, userId);
// import existing trip photos (Immich/Synology) with sharing settings
syncTripPhotos(journeyId, tripId);
broadcastJourneyEvent(journeyId, 'journey:trip:synced', { tripId }, userId);
return true;
}
export function removeTripFromJourney(journeyId: number, tripId: number, userId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
// remove skeleton entries that haven't been filled in
db.prepare(`
DELETE FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND type = 'skeleton'
`).run(journeyId, tripId);
// detach filled entries from this trip
db.prepare(`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
WHERE journey_id = ? AND source_trip_id = ? AND type != 'skeleton'
`).run(journeyId, tripId);
db.prepare('DELETE FROM journey_trips WHERE journey_id = ? AND trip_id = ?').run(journeyId, tripId);
return true;
}
// ── Sync engine ──────────────────────────────────────────────────────────
export function syncTripPlaces(journeyId: number, tripId: number, authorId: number) {
const places = db.prepare(`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, da.assignment_end_time, d.day_number
FROM places p
INNER JOIN day_assignments da ON da.place_id = p.id
INNER JOIN days d ON da.day_id = d.id
WHERE p.trip_id = ?
ORDER BY d.day_number ASC, da.order_index ASC
`).all(tripId) as any[];
const now = ts();
const existing = db.prepare(
'SELECT source_place_id FROM journey_entries WHERE journey_id = ? AND source_trip_id = ?'
).all(journeyId, tripId) as { source_place_id: number }[];
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
for (const place of places) {
if (existingPlaceIds.has(place.id)) continue;
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
const entryTime = place.assignment_time || place.place_time || null;
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
journeyId, tripId, place.id, authorId,
place.name, entryDate, entryTime,
place.address || place.name, place.lat || null, place.lng || null,
place.day_number || 0, now, now
);
}
}
// import trip_photos into journey when a trip is linked
function syncTripPhotos(journeyId: number, tripId: number) {
const tripPhotos = db.prepare(
'SELECT * FROM trip_photos WHERE trip_id = ?'
).all(tripId) as { id: number; trip_id: number; user_id: number; asset_id: string; provider: string; shared: number }[];
if (!tripPhotos.length) return;
const now = ts();
// find or create a "Photos" entry for this trip's photos
let photoEntry = db.prepare(`
SELECT id FROM journey_entries
WHERE journey_id = ? AND source_trip_id = ? AND title = '[Trip Photos]' AND type = 'entry'
`).get(journeyId, tripId) as { id: number } | undefined;
if (!photoEntry) {
// get trip date for the entry
const trip = db.prepare('SELECT start_date FROM trips WHERE id = ?').get(tripId) as { start_date: string } | undefined;
const entryDate = trip?.start_date || new Date().toISOString().split('T')[0];
const owner = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(journeyId) as { user_id: number };
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, sort_order, created_at, updated_at)
VALUES (?, ?, ?, 'entry', '[Trip Photos]', ?, 999, ?, ?)
`).run(journeyId, tripId, owner.user_id, entryDate, now, now);
photoEntry = { id: Number(res.lastInsertRowid) };
}
// import each trip photo, skip duplicates
for (const tp of tripPhotos) {
const exists = db.prepare(
'SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?'
).get(photoEntry.id, tp.provider, tp.asset_id);
if (exists) continue;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(photoEntry.id) as { m: number | null };
db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, shared, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(photoEntry.id, tp.provider, tp.asset_id, tp.user_id, tp.shared, (maxOrder?.m ?? -1) + 1, now);
}
}
// called when a trip place is created
export function onPlaceCreated(tripId: number, placeId: number) {
const links = db.prepare('SELECT journey_id FROM journey_trips WHERE trip_id = ?').all(tripId) as { journey_id: number }[];
if (!links.length) return;
const place = db.prepare(`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
FROM places p
INNER JOIN day_assignments da ON da.place_id = p.id
INNER JOIN days d ON da.day_id = d.id
WHERE p.id = ?
`).get(placeId) as any;
if (!place) return; // not assigned to a day yet — skip
const now = ts();
for (const link of links) {
const already = db.prepare(
'SELECT 1 FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
).get(link.journey_id, placeId);
if (already) continue;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
const entryDate = place.day_date;
db.prepare(`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run(
link.journey_id, tripId, placeId, journey.user_id,
place.name, entryDate, place.assignment_time || place.place_time || null,
place.address || place.name, place.lat || null, place.lng || null,
now, now
);
}
}
// called when a trip place is updated
export function onPlaceUpdated(placeId: number) {
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE source_place_id = ?'
).all(placeId) as JourneyEntry[];
if (!entries.length) return;
const place = db.prepare(`
SELECT p.*, da.day_id, d.date as day_date, da.assignment_time, d.day_number
FROM places p
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON da.day_id = d.id
WHERE p.id = ?
`).get(placeId) as any;
if (!place) return;
const now = ts();
for (const entry of entries) {
if (entry.type === 'skeleton') {
// update everything on skeletons
db.prepare(`
UPDATE journey_entries SET title = ?, entry_date = ?, entry_time = ?, location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
WHERE id = ?
`).run(
place.name,
place.day_date || entry.entry_date,
place.assignment_time || place.place_time || entry.entry_time,
place.address || place.name,
place.lat || null, place.lng || null,
now, entry.id
);
} else {
// for filled entries, only update location silently
db.prepare(`
UPDATE journey_entries SET location_name = ?, location_lat = ?, location_lng = ?, updated_at = ?
WHERE id = ?
`).run(place.address || place.name, place.lat || null, place.lng || null, now, entry.id);
}
}
}
// called when a trip place is deleted
export function onPlaceDeleted(placeId: number) {
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE source_place_id = ?'
).all(placeId) as JourneyEntry[];
for (const entry of entries) {
if (entry.type === 'skeleton') {
// no content: just delete
const hasPhotos = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(entry.id);
if (!hasPhotos && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entry.id);
continue;
}
}
// entry has content: keep it, detach, add note
const note = '\n\n> _Note: the original trip place was removed from the trip plan_';
const newStory = (entry.story || '') + note;
db.prepare(
'UPDATE journey_entries SET source_place_id = NULL, source_trip_id = NULL, type = ?, story = ?, updated_at = ? WHERE id = ?'
).run(entry.type === 'skeleton' ? 'entry' : entry.type, newStory, ts(), entry.id);
}
}
// ── Entries ──────────────────────────────────────────────────────────────
export function listEntries(journeyId: number, userId: number) {
if (!canAccessJourney(journeyId, userId)) return null;
const entries = db.prepare(
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
).all(journeyId) as JourneyEntry[];
const photos = db.prepare(
'SELECT * FROM journey_photos WHERE entry_id IN (SELECT id FROM journey_entries WHERE journey_id = ?) ORDER BY sort_order ASC'
).all(journeyId) as JourneyPhoto[];
const photosByEntry: Record<number, JourneyPhoto[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
return entries.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
source_trip_name: e.source_trip_id
? (db.prepare('SELECT title FROM trips WHERE id = ?').get(e.source_trip_id) as { title: string } | undefined)?.title || null
: null,
}));
}
export function createEntry(journeyId: number, userId: number, data: {
type?: string;
title?: string;
story?: string;
entry_date: string;
entry_time?: string;
location_name?: string;
location_lat?: number;
location_lng?: number;
mood?: string;
weather?: string;
tags?: string[];
pros_cons?: { pros: string[]; cons: string[] };
visibility?: string;
}): JourneyEntry | null {
if (!canEdit(journeyId, userId)) return null;
const now = ts();
const maxOrder = db.prepare(
'SELECT MAX(sort_order) as m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
).get(journeyId, data.entry_date) as { m: number | null };
const prosConsJson = data.pros_cons && (data.pros_cons.pros.length || data.pros_cons.cons.length)
? JSON.stringify(data.pros_cons) : null;
const res = db.prepare(`
INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, tags, pros_cons, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
journeyId, userId,
data.type || 'entry',
data.title || null,
data.story || null,
data.entry_date,
data.entry_time || null,
data.location_name || null,
data.location_lat ?? null,
data.location_lng ?? null,
data.mood || null,
data.weather || null,
data.tags?.length ? JSON.stringify(data.tags) : null,
prosConsJson,
data.visibility || 'private',
(maxOrder?.m ?? -1) + 1,
now, now
);
const created = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyEntry;
broadcastJourneyEvent(journeyId, 'journey:entry:created', { entry: created }, userId);
return created;
}
export function updateEntry(entryId: number, userId: number, data: Partial<{
type: string;
title: string;
story: string;
entry_date: string;
entry_time: string;
location_name: string;
location_lat: number;
location_lng: number;
mood: string;
weather: string;
tags: string[];
pros_cons: { pros: string[]; cons: string[] };
visibility: string;
sort_order: number;
}>): JourneyEntry | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(data)) {
if (val === undefined) continue;
if (key === 'tags') {
fields.push('tags = ?');
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
} else if (key === 'pros_cons') {
fields.push('pros_cons = ?');
values.push(val && typeof val === 'object' ? JSON.stringify(val) : val);
} else {
fields.push(`${key} = ?`);
values.push(val);
}
}
// if adding story to a skeleton, promote to entry
if (entry.type === 'skeleton' && data.story && data.story.trim()) {
fields.push('type = ?');
values.push('entry');
}
if (fields.length === 0) return entry;
fields.push('updated_at = ?');
values.push(ts());
values.push(entryId);
db.prepare(`UPDATE journey_entries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
// touch the journey
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(ts(), entry.journey_id);
const updated = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry;
broadcastJourneyEvent(entry.journey_id, 'journey:entry:updated', { entry: updated }, userId);
return updated;
}
export function deleteEntry(entryId: number, userId: number): boolean {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return false;
if (!canEdit(entry.journey_id, userId)) return false;
// delete photos along with the entry — no more orphan Gallery entries
db.prepare('DELETE FROM journey_photos WHERE entry_id = ?').run(entryId);
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
// clean up any empty Gallery entries in this journey
db.prepare(`
DELETE FROM journey_entries WHERE journey_id = ? AND title = 'Gallery'
AND id NOT IN (SELECT DISTINCT entry_id FROM journey_photos)
`).run(entry.journey_id);
broadcastJourneyEvent(entry.journey_id, 'journey:entry:deleted', { entryId }, userId);
return true;
}
// ── Photos ───────────────────────────────────────────────────────────────
export function addPhoto(entryId: number, userId: number, filePath: string, thumbnailPath?: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, file_path, thumbnail_path, caption, sort_order, created_at)
VALUES (?, 'local', ?, ?, ?, ?, ?)
`).run(entryId, filePath, thumbnailPath || null, caption || null, (maxOrder?.m ?? -1) + 1, now);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function addProviderPhoto(entryId: number, userId: number, provider: string, assetId: string, caption?: string): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
// skip if already added
const exists = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ? AND provider = ? AND asset_id = ?').get(entryId, provider, assetId);
if (exists) return null;
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM journey_photos WHERE entry_id = ?').get(entryId) as { m: number | null };
const now = ts();
const res = db.prepare(`
INSERT INTO journey_photos (entry_id, provider, asset_id, owner_id, caption, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(entryId, provider, assetId, userId, caption || null, (maxOrder?.m ?? -1) + 1, now);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(Number(res.lastInsertRowid)) as JourneyPhoto;
}
export function linkPhotoToEntry(entryId: number, photoId: number, userId: number): JourneyPhoto | null {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
if (!entry) return null;
if (!canEdit(entry.journey_id, userId)) return null;
const source = db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto | undefined;
if (!source) return null;
if (source.entry_id === entryId) return source;
const oldEntryId = source.entry_id;
// move photo to the target entry
db.prepare('UPDATE journey_photos SET entry_id = ? WHERE id = ?').run(entryId, photoId);
// clean up: if old entry was a "Gallery" entry and is now empty, delete it
const oldEntry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(oldEntryId) as JourneyEntry | undefined;
if (oldEntry && oldEntry.title === 'Gallery') {
const remaining = db.prepare('SELECT COUNT(*) as c FROM journey_photos WHERE entry_id = ?').get(oldEntryId) as { c: number };
if (remaining.c === 0) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(oldEntryId);
}
}
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
}
export function setPhotoProvider(photoId: number, provider: string, assetId: string, ownerId: number) {
db.prepare('UPDATE journey_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?').run(provider, assetId, ownerId, photoId);
}
export function updatePhoto(photoId: number, userId: number, data: { caption?: string; sort_order?: number }): JourneyPhoto | null {
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
const fields: string[] = [];
const values: unknown[] = [];
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
if (!fields.length) return photo;
values.push(photoId);
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return db.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photoId) as JourneyPhoto;
}
export function deletePhoto(photoId: number, userId: number): (JourneyPhoto & { journey_id: number }) | null {
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ?
`).get(photoId) as (JourneyPhoto & { journey_id: number }) | undefined;
if (!photo) return null;
if (!canEdit(photo.journey_id, userId)) return null;
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
// clean up empty Gallery entries left behind
const remaining = db.prepare('SELECT 1 FROM journey_photos WHERE entry_id = ?').get(photo.entry_id);
if (!remaining) {
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(photo.entry_id) as JourneyEntry | undefined;
if (entry && entry.title === 'Gallery' && !entry.story) {
db.prepare('DELETE FROM journey_entries WHERE id = ?').run(photo.entry_id);
}
}
return photo;
}
// ── Contributors ─────────────────────────────────────────────────────────
export function addContributor(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
if (!isOwner(journeyId, userId)) return false;
if (targetUserId === userId) return false;
try {
db.prepare(
'INSERT OR REPLACE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)'
).run(journeyId, targetUserId, role, ts());
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
return true;
} catch { return false; }
}
export function updateContributorRole(journeyId: number, userId: number, targetUserId: number, role: 'editor' | 'viewer'): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare(
'UPDATE journey_contributors SET role = ? WHERE journey_id = ? AND user_id = ?'
).run(role, journeyId, targetUserId);
broadcastJourneyEvent(journeyId, 'journey:contributor:changed', { targetUserId, role });
return true;
}
export function removeContributor(journeyId: number, userId: number, targetUserId: number): boolean {
if (!isOwner(journeyId, userId)) return false;
db.prepare(
"DELETE FROM journey_contributors WHERE journey_id = ? AND user_id = ? AND role != 'owner'"
).run(journeyId, targetUserId);
return true;
}
// ── Suggestions ──────────────────────────────────────────────────────────
export function getSuggestions(userId: number) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
return db.prepare(`
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
(SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count
FROM trips t
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
WHERE (t.user_id = ? OR tm.user_id = ?)
AND t.end_date IS NOT NULL
AND t.end_date >= ?
AND t.end_date <= date('now')
AND t.id NOT IN (SELECT trip_id FROM journey_trips)
ORDER BY t.end_date DESC
`).all(userId, userId, userId, thirtyDaysAgo);
}
// ── User trips (for trip picker) ─────────────────────────────────────────
export function listUserTrips(userId: number) {
return db.prepare(`
SELECT t.id, t.title, t.start_date, t.end_date, t.cover_image,
(SELECT COUNT(*) FROM places p INNER JOIN day_assignments da ON da.place_id = p.id WHERE p.trip_id = t.id) as place_count
FROM trips t
LEFT JOIN trip_members tm ON t.id = tm.trip_id AND tm.user_id = ?
WHERE t.user_id = ? OR tm.user_id = ?
ORDER BY t.start_date DESC
`).all(userId, userId, userId);
}
+149
View File
@@ -0,0 +1,149 @@
import { db } from '../db/database';
import crypto from 'crypto';
interface JourneySharePermissions {
share_timeline?: boolean;
share_gallery?: boolean;
share_map?: boolean;
}
interface JourneyShareTokenInfo {
token: string;
created_at: string;
share_timeline: boolean;
share_gallery: boolean;
share_map: boolean;
}
export function createOrUpdateJourneyShareLink(
journeyId: number,
createdBy: number,
permissions: JourneySharePermissions
): { token: string; created: boolean } {
const {
share_timeline = true,
share_gallery = true,
share_map = true,
} = permissions;
const existing = db.prepare('SELECT token FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as { token: string } | undefined;
if (existing) {
db.prepare('UPDATE journey_share_tokens SET share_timeline = ?, share_gallery = ?, share_map = ? WHERE journey_id = ?')
.run(share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0, journeyId);
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO journey_share_tokens (journey_id, token, created_by, share_timeline, share_gallery, share_map) VALUES (?, ?, ?, ?, ?, ?)')
.run(journeyId, token, createdBy, share_timeline ? 1 : 0, share_gallery ? 1 : 0, share_map ? 1 : 0);
return { token, created: true };
}
export function getJourneyShareLink(journeyId: number): JourneyShareTokenInfo | null {
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE journey_id = ?').get(journeyId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
};
}
export function deleteJourneyShareLink(journeyId: number): void {
db.prepare('DELETE FROM journey_share_tokens WHERE journey_id = ?').run(journeyId);
}
export function validateShareTokenForPhoto(token: string, photoId: number): { journeyId: number; ownerId: number } | null {
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const photo = db.prepare(`
SELECT jp.*, je.journey_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.id = ? AND je.journey_id = ?
`).get(photoId, row.journey_id) as any;
if (!photo) return null;
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { journeyId: row.journey_id, ownerId: photo.owner_id || journey.user_id } : null;
}
export function validateShareTokenForAsset(token: string, assetId: string): { ownerId: number } | null {
const row = db.prepare('SELECT journey_id FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
// Check if this asset belongs to any photo in the shared journey
const photo = db.prepare(`
SELECT jp.owner_id FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE jp.asset_id = ? AND je.journey_id = ?
`).get(assetId, row.journey_id) as any;
if (!photo) {
// Fallback: get journey owner
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null;
}
return { ownerId: photo.owner_id };
}
export function getPublicJourney(token: string) {
const row = db.prepare('SELECT * FROM journey_share_tokens WHERE token = ?').get(token) as any;
if (!row) return null;
const journey = db.prepare('SELECT * FROM journeys WHERE id = ?').get(row.journey_id) as any;
if (!journey) return null;
// Entries with photos
const entries = db.prepare(`
SELECT je.* FROM journey_entries je
WHERE je.journey_id = ? AND je.type != 'skeleton'
ORDER BY je.entry_date, je.sort_order
`).all(row.journey_id) as any[];
const photos = db.prepare(`
SELECT jp.* FROM journey_photos jp
JOIN journey_entries je ON jp.entry_id = je.id
WHERE je.journey_id = ?
ORDER BY jp.sort_order
`).all(row.journey_id) as any[];
const photosByEntry: Record<number, any[]> = {};
for (const p of photos) {
(photosByEntry[p.entry_id] ||= []).push(p);
}
const enrichedEntries = entries
.filter(e => {
// hide empty Gallery entries (no photos, no story)
if (e.title === 'Gallery' && !e.story && !(photosByEntry[e.id]?.length)) return false;
return true;
})
.map(e => ({
...e,
tags: e.tags ? JSON.parse(e.tags) : [],
pros_cons: e.pros_cons ? JSON.parse(e.pros_cons) : null,
photos: photosByEntry[e.id] || [],
}));
// Stats
const stats = {
entries: entries.length,
photos: photos.length,
cities: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
return {
journey: {
title: journey.title,
subtitle: journey.subtitle,
cover_image: journey.cover_image,
status: journey.status,
},
entries: enrichedEntries,
stats,
permissions: {
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
},
};
}
+5 -2
View File
@@ -43,7 +43,7 @@ interface GooglePlaceDetails extends GooglePlaceResult {
// ── Constants ────────────────────────────────────────────────────────────────
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/NOMAD)';
const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
// ── Photo cache ──────────────────────────────────────────────────────────────
@@ -89,7 +89,10 @@ export async function searchNominatim(query: string, lang?: string) {
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: { 'User-Agent': UA },
});
if (!response.ok) throw new Error('Nominatim API error');
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`);
}
const data = await response.json() as NominatimResult[];
return data.map(item => ({
google_place_id: null,
@@ -123,6 +123,31 @@ export function canAccessUserPhoto(requestingUserId: number, ownerUserId: number
if (requestingUserId === ownerUserId) {
return true;
}
// Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') {
const journeyPhoto = db.prepare(`
SELECT jp.entry_id, je.journey_id
FROM journey_photos jp
JOIN journey_entries je ON je.id = jp.entry_id
WHERE jp.asset_id = ?
AND jp.provider = ?
AND jp.owner_id = ?
LIMIT 1
`).get(assetId, provider, ownerUserId) as { entry_id: number; journey_id: number } | undefined;
if (!journeyPhoto) return false;
// Check if requesting user is the journey owner or a contributor
const access = db.prepare(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL
SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?
LIMIT 1
`).get(journeyPhoto.journey_id, requestingUserId, journeyPhoto.journey_id, requestingUserId);
return !!access;
}
// Regular trip photos
const sharedAsset = db.prepare(`
SELECT 1
FROM trip_photos
@@ -357,3 +357,63 @@ export async function syncAlbumAssets(
return { error: 'Could not reach Immich', status: 502 };
}
}
// ── Upload to Immich ──────────────────────────────────────────────────────
export async function uploadToImmich(userId: number, filePath: string, fileName: string): Promise<string | null> {
const creds = getImmichCredentials(userId);
if (!creds) return null;
const fs = await import('node:fs');
const path = await import('node:path');
const fullPath = path.join(__dirname, '../../../uploads', filePath);
if (!fs.existsSync(fullPath)) return null;
try {
const fileBuffer = fs.readFileSync(fullPath);
const boundary = '----ImmichUpload' + Date.now();
const ext = path.extname(fileName).toLowerCase();
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic',
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
const now = new Date().toISOString();
const parts: Buffer[] = [];
const addField = (name: string, value: string) => {
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`));
};
addField('deviceAssetId', `trek-${Date.now()}`);
addField('deviceId', 'TREK');
addField('fileCreatedAt', now);
addField('fileModifiedAt', now);
parts.push(Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="assetData"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`
));
parts.push(fileBuffer);
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await safeFetch(`${creds.immich_url}/api/assets`, {
method: 'POST',
headers: {
'x-api-key': creds.immich_api_key,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(body.length),
},
body,
});
if (res.ok) {
const data = await res.json() as { id?: string };
return data.id || null;
}
return null;
} catch {
return null;
}
}
+130 -30
View File
@@ -19,26 +19,62 @@ import {
SyncAlbumResult,
AssetInfo
} from './helpersService';
import { send as sendNotification } from '../notificationService';
const SYNOLOGY_PROVIDER = 'synologyphotos';
const SYNOLOGY_ENDPOINT_PATH = '/photo/webapi/entry.cgi';
// Users provide the full base URL including the Photos app path (e.g. https://nas:5001/photo).
// The API endpoint is always at {base_url}/webapi/entry.cgi.
const SYNOLOGY_ENDPOINT_PATH = '/webapi/entry.cgi';
const SYNOLOGY_ERROR_MESSAGES: Record<number, string> = {
101: 'Missing API, method, or version parameter.',
102: 'Requested API does not exist.',
103: 'Requested method does not exist.',
104: 'Requested API version is not supported.',
105: 'Insufficient privilege.',
106: 'Connection timeout.',
107: 'Multiple logins blocked from this IP.',
117: 'Manager privilege required.',
119: 'Session is invalid or expired.',
400: 'Invalid credentials.',
401: 'Session expired or account disabled.',
402: 'No permission to use this account.',
403: 'Two-factor authentication code required.',
404: 'Two-factor authentication failed.',
406: 'Two-factor authentication is enforced for this account.',
407: 'Maximum login attempts reached.',
408: 'Password expired.',
409: 'Remote password expired.',
410: 'Password must be changed before login.',
412: 'Guest account cannot log in.',
413: 'OTP system files are corrupted.',
414: 'Unable to log in.',
416: 'Unable to log in.',
417: 'OTP system is full.',
498: 'System is upgrading.',
499: 'System is not ready.',
};
interface SynologyUserRecord {
synology_url?: string | null;
synology_username?: string | null;
synology_password?: string | null;
synology_sid?: string | null;
synology_did?: string | null;
synology_skip_ssl?: number | null;
};
interface SynologyCredentials {
synology_url: string;
synology_username: string;
synology_password: string;
synology_skip_ssl: boolean;
}
interface SynologySettings {
synology_url: string;
synology_username: string;
synology_skip_ssl: boolean;
connected: boolean;
}
@@ -84,7 +120,7 @@ interface SynologyPhotoItem {
function _readSynologyUser(userId: number, columns: string[]): ServiceResult<SynologyUserRecord> {
try {
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
const row = db.prepare(`SELECT synology_url, synology_username, synology_password, synology_sid, synology_did, synology_skip_ssl FROM users WHERE id = ?`).get(userId) as SynologyUserRecord | undefined;
if (!row) {
return fail('User not found', 404);
@@ -102,7 +138,7 @@ function _readSynologyUser(userId: number, columns: string[]): ServiceResult<Syn
}
function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredentials> {
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password']);
const user = _readSynologyUser(userId, ['synology_url', 'synology_username', 'synology_password', 'synology_skip_ssl']);
if (!user.success) return user as ServiceResult<SynologyCredentials>;
if (!user?.data.synology_url || !user.data.synology_username || !user.data.synology_password) return fail('Synology not configured', 400);
const password = decrypt_api_key(user.data.synology_password);
@@ -111,6 +147,7 @@ function _getSynologyCredentials(userId: number): ServiceResult<SynologyCredenti
synology_url: user.data.synology_url,
synology_username: user.data.synology_username,
synology_password: password,
synology_skip_ssl: user.data.synology_skip_ssl !== 0,
});
}
@@ -129,7 +166,7 @@ function _buildSynologyFormBody(params: ApiCallParams): URLSearchParams {
return body;
}
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promise<ServiceResult<T>> {
async function _fetchSynologyJson<T>(url: string, body: URLSearchParams, skipSsl = true): Promise<ServiceResult<T>> {
const endpoint = _buildSynologyEndpoint(url, `api=${body.get('api')}`);
try {
const resp = await safeFetch(endpoint, {
@@ -139,12 +176,20 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
},
body,
signal: AbortSignal.timeout(30000) as any,
});
}, { rejectUnauthorized: !skipSsl });
if (!resp.ok) {
return fail('Synology API request failed with status ' + resp.status, resp.status);
}
const response = await resp.json() as SynologyApiResponse<T>;
return response.success ? success(response.data) : fail('Synology failed with code ' + response.error.code, response.error.code);
if (!response.success) {
const code = response.error.code;
const message = SYNOLOGY_ERROR_MESSAGES[code] ?? 'Synology API request failed (code ' + code + ')';
// Preserve session error codes (106, 107, 119) for internal retry logic in _requestSynologyApi.
// All other Synology app-level codes are mapped to HTTP 400 — they are not HTTP status codes.
const httpStatus = [106, 107, 119].includes(code) ? code : 400;
return fail(message, httpStatus);
}
return success(response.data);
} catch (error) {
if (error instanceof SsrfBlockedError) {
return fail(error.message, 400);
@@ -153,25 +198,41 @@ async function _fetchSynologyJson<T>(url: string, body: URLSearchParams): Promis
}
}
async function _loginToSynology(url: string, username: string, password: string): Promise<ServiceResult<string>> {
const SYNOLOGY_DEVICE_NAME = 'trek';
async function _loginToSynology(
url: string,
username: string,
password: string,
opts: { otp?: string; deviceId?: string; skipSsl?: boolean } = {},
): Promise<ServiceResult<{ sid: string; did?: string }>> {
const { otp, deviceId, skipSsl = false } = opts;
const body = new URLSearchParams({
api: 'SYNO.API.Auth',
method: 'login',
version: '3',
version: '6',
account: username,
passwd: password,
format: 'sid',
client: 'browser',
device_name: SYNOLOGY_DEVICE_NAME,
});
if (otp && otp.trim()) {
body.append('otp_code', otp.trim());
body.append('enable_device_token', 'yes');
}
if (deviceId) {
body.append('device_id', deviceId);
}
const result = await _fetchSynologyJson<{ sid?: string }>(url, body);
const result = await _fetchSynologyJson<{ sid?: string; did?: string }>(url, body, skipSsl);
if (!result.success) {
return result as ServiceResult<string>;
return result as ServiceResult<{ sid: string; did?: string }>;
}
if (!result.data.sid) {
return fail('Failed to get session ID from Synology', 500);
}
return success(result.data.sid);
return success({ sid: result.data.sid, did: result.data.did });
}
async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Promise<ServiceResult<T>> {
@@ -185,8 +246,9 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
return session as ServiceResult<T>;
}
const skipSsl = creds.data.synology_skip_ssl;
const body = _buildSynologyFormBody({ ...params, _sid: session.data });
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body);
const result = await _fetchSynologyJson<T>(creds.data.synology_url, body, skipSsl);
// 106 = session timeout, 107 = duplicate login kicked us out, 119 = SID not found/invalid
if ('error' in result && [106, 107, 119].includes(result.error.status)) {
_clearSynologySID(userId);
@@ -194,7 +256,7 @@ async function _requestSynologyApi<T>(userId: number, params: ApiCallParams): Pr
if (!retrySession.success || !retrySession.data) {
return retrySession as ServiceResult<T>;
}
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }));
return _fetchSynologyJson<T>(creds.data.synology_url, _buildSynologyFormBody({ ...params, _sid: retrySession.data }), skipSsl);
}
return result;
}
@@ -232,6 +294,10 @@ function _clearSynologySID(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL WHERE id = ?').run(userId);
}
function _clearSynologySession(userId: number): void {
db.prepare('UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?').run(userId);
}
function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string; assetId: string } | null {
// cache_key format from Synology is "{unit_id}_{timestamp}", e.g. "40808_1633659236".
// The first segment must be a non-empty integer (the unit ID used for API calls).
@@ -241,9 +307,9 @@ function _splitPackedSynologyId(rawId: string): { id: string; cacheKey: string;
}
async function _getSynologySession(userId: number): Promise<ServiceResult<string>> {
const cachedSid = _readSynologyUser(userId, ['synology_sid']);
if (cachedSid.success && cachedSid.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cachedSid.data.synology_sid);
const cached = _readSynologyUser(userId, ['synology_sid', 'synology_did']);
if (cached.success && cached.data?.synology_sid) {
const decryptedSid = decrypt_api_key(cached.data.synology_sid);
if (decryptedSid) return success(decryptedSid);
// Decryption failed (e.g. key rotation) — clear the stale SID and re-login
_clearSynologySID(userId);
@@ -254,15 +320,22 @@ async function _getSynologySession(userId: number): Promise<ServiceResult<string
return creds as ServiceResult<string>;
}
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password);
// Use stored device ID to skip OTP on re-login (trusted device flow)
const storedDid = cached.success && cached.data?.synology_did
? (decrypt_api_key(cached.data.synology_did) || undefined)
: undefined;
const resp = await _loginToSynology(creds.data.synology_url, creds.data.synology_username, creds.data.synology_password, {
deviceId: storedDid,
skipSsl: creds.data.synology_skip_ssl,
});
if (!resp.success) {
return resp as ServiceResult<string>;
}
const encrypted = encrypt_api_key(resp.data);
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypted, userId);
return success(resp.data);
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
return success(resp.data.sid);
}
export async function getSynologySettings(userId: number): Promise<ServiceResult<SynologySettings>> {
@@ -272,11 +345,12 @@ export async function getSynologySettings(userId: number): Promise<ServiceResult
return success({
synology_url: creds.data.synology_url || '',
synology_username: creds.data.synology_username || '',
synology_skip_ssl: creds.data.synology_skip_ssl,
connected: session.success,
});
}
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string): Promise<ServiceResult<string>> {
export async function updateSynologySettings(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword?: string, synologySkipSsl = false): Promise<ServiceResult<string>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
@@ -291,24 +365,42 @@ export async function updateSynologySettings(userId: number, synologyUrl: string
return fail('No stored password found. Please provide a password to save settings.', 400);
}
// Only invalidate the session when the account itself changes (different URL or username).
// If the user just tested the connection, testSynologyConnection already stored a fresh
// sid + did — clearing them here would force an unnecessary re-login that may fail (MFA).
const existing = _readSynologyUser(userId, ['synology_url', 'synology_username']);
const urlChanged = existing.success && existing.data.synology_url !== synologyUrl;
const userChanged = existing.success && existing.data.synology_username !== synologyUsername;
const sessionCleared = urlChanged || userChanged;
if (sessionCleared) {
_clearSynologySession(userId);
sendNotification({
event: 'synology_session_cleared',
actorId: null,
params: {},
scope: 'user',
targetId: userId,
});
}
try {
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ? WHERE id = ?').run(
db.prepare('UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?').run(
synologyUrl,
synologyUsername,
synologyPassword ? maybe_encrypt_api_key(synologyPassword) : existingEncryptedPassword,
synologySkipSsl ? 1 : 0,
userId,
);
} catch {
return fail('Failed to update Synology settings', 500);
}
_clearSynologySID(userId);
return success("settings updated");
return success('settings updated');
}
export async function getSynologyStatus(userId: number): Promise<ServiceResult<StatusResult>> {
const sid = await _getSynologySession(userId);
if ('error' in sid) return success({ connected: false, error: sid.error.status === 400 ? 'Invalid credentials' : sid.error.message });
if ('error' in sid) return success({ connected: false, error: sid.error.message });
if (!sid.data) return success({ connected: false, error: 'Not connected to Synology' });
try {
const user = db.prepare('SELECT synology_username FROM users WHERE id = ?').get(userId) as { synology_username?: string } | undefined;
@@ -318,17 +410,25 @@ export async function getSynologyStatus(userId: number): Promise<ServiceResult<S
}
}
export async function testSynologyConnection(synologyUrl: string, synologyUsername: string, synologyPassword: string): Promise<ServiceResult<StatusResult>> {
export async function testSynologyConnection(userId: number, synologyUrl: string, synologyUsername: string, synologyPassword: string, synologyOtp?: string, synologySkipSsl = false): Promise<ServiceResult<StatusResult>> {
const ssrf = await checkSsrf(synologyUrl);
if (!ssrf.allowed) {
return fail(ssrf.error, 400);
}
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword);
const resp = await _loginToSynology(synologyUrl, synologyUsername, synologyPassword, { otp: synologyOtp, skipSsl: synologySkipSsl });
if ('error' in resp) {
return success({ connected: false, error: resp.error.status === 400 ? 'Invalid credentials' : resp.error.message });
return success({ connected: false, error: resp.error.message });
}
// Persist the session so the OTP code is not required again on save.
// The did (device token) allows future re-logins without OTP.
db.prepare('UPDATE users SET synology_sid = ? WHERE id = ?').run(encrypt_api_key(resp.data.sid), userId);
if (resp.data.did) {
db.prepare('UPDATE users SET synology_did = ? WHERE id = ?').run(encrypt_api_key(resp.data.did), userId);
}
return success({ connected: true, user: { name: synologyUsername } });
}
@@ -13,7 +13,8 @@ export type NotifEventType =
| 'photos_shared'
| 'collab_message'
| 'packing_tagged'
| 'version_available';
| 'version_available'
| 'synology_session_cleared';
export interface AvailableChannels {
email: boolean;
@@ -32,6 +33,7 @@ const IMPLEMENTED_COMBOS: Record<NotifEventType, NotifChannel[]> = {
collab_message: ['inapp', 'email', 'webhook'],
packing_tagged: ['inapp', 'email', 'webhook'],
version_available: ['inapp', 'email', 'webhook'],
synology_session_cleared: ['inapp'],
};
/** Events that target admins only (shown in admin panel, not in user settings). */
@@ -112,6 +112,12 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_admin',
navigateTarget: () => '/admin',
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
textKey: 'notifications.synologySessionCleared.text',
navigateTarget: () => null,
},
};
// ── Fallback config for unknown event types ────────────────────────────────
+15
View File
@@ -104,6 +104,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
version_available: p => ({ title: 'New TREK version available', body: `TREK ${p.version} is now available. Visit the admin panel to update.` }),
synology_session_cleared: () => ({ title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.' }),
},
de: {
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat ${p.invitee || 'ein Mitglied'} zur Reise "${p.trip}" eingeladen.` }),
@@ -114,6 +115,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
version_available: p => ({ title: 'Neue TREK-Version verfügbar', body: `TREK ${p.version} ist jetzt verfügbar. Besuche das Admin-Panel zum Aktualisieren.` }),
synology_session_cleared: () => ({ title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.' }),
},
fr: {
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} a invité ${p.invitee || 'un membre'} au voyage "${p.trip}".` }),
@@ -124,6 +126,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
version_available: p => ({ title: 'Nouvelle version TREK disponible', body: `TREK ${p.version} est maintenant disponible. Rendez-vous dans le panneau d'administration pour mettre à jour.` }),
synology_session_cleared: () => ({ title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.' }),
},
es: {
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} invitó a ${p.invitee || 'un miembro'} al viaje "${p.trip}".` }),
@@ -134,6 +137,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
version_available: p => ({ title: 'Nueva versión de TREK disponible', body: `TREK ${p.version} ya está disponible. Visita el panel de administración para actualizar.` }),
synology_session_cleared: () => ({ title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.' }),
},
nl: {
trip_invite: p => ({ title: `Uitnodiging voor "${p.trip}"`, body: `${p.actor} heeft ${p.invitee || 'een lid'} uitgenodigd voor de reis "${p.trip}".` }),
@@ -144,6 +148,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
version_available: p => ({ title: 'Nieuwe TREK-versie beschikbaar', body: `TREK ${p.version} is nu beschikbaar. Bezoek het beheerderspaneel om bij te werken.` }),
synology_session_cleared: () => ({ title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.' }),
},
ru: {
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил ${p.invitee || 'участника'} в поездку "${p.trip}".` }),
@@ -154,6 +159,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
version_available: p => ({ title: 'Доступна новая версия TREK', body: `TREK ${p.version} теперь доступен. Перейдите в панель администратора для обновления.` }),
synology_session_cleared: () => ({ title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.' }),
},
zh: {
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请了 ${p.invitee || '成员'} 加入旅行"${p.trip}"。` }),
@@ -164,6 +170,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}${p.preview}` }),
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 现已可用。请前往管理面板进行更新。` }),
synology_session_cleared: () => ({ title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。' }),
},
'zh-TW': {
trip_invite: p => ({ title: `邀請加入「${p.trip}`, body: `${p.actor} 邀請了 ${p.invitee || '成員'} 加入行程「${p.trip}」。` }),
@@ -173,6 +180,8 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
photos_shared: p => ({ title: `已分享 ${p.count} 張照片`, body: `${p.actor} 在「${p.trip}」中分享了 ${p.count} 張照片。` }),
collab_message: p => ({ title: `${p.trip}」中的新訊息`, body: `${p.actor}${p.preview}` }),
packing_tagged: p => ({ title: `打包清單:${p.category}`, body: `${p.actor} 已將您指派到「${p.trip}」中的「${p.category}」分類。` }),
version_available: p => ({ title: '新版 TREK 可用', body: `TREK ${p.version} 現已可用。請前往管理面板進行更新。` }),
synology_session_cleared: () => ({ title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。' }),
},
ar: {
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعا ${p.invitee || 'عضو'} إلى الرحلة "${p.trip}".` }),
@@ -183,6 +192,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
version_available: p => ({ title: 'إصدار TREK جديد متاح', body: `TREK ${p.version} متاح الآن. تفضل بزيارة لوحة الإدارة للتحديث.` }),
synology_session_cleared: () => ({ title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.' }),
},
br: {
trip_invite: p => ({ title: `Convite para "${p.trip}"`, body: `${p.actor} convidou ${p.invitee || 'um membro'} para a viagem "${p.trip}".` }),
@@ -193,6 +203,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nova mensagem em "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Bagagem: ${p.category}`, body: `${p.actor} atribuiu você à categoria "${p.category}" em "${p.trip}".` }),
version_available: p => ({ title: 'Nova versão do TREK disponível', body: `O TREK ${p.version} está disponível. Acesse o painel de administração para atualizar.` }),
synology_session_cleared: () => ({ title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.' }),
},
cs: {
trip_invite: p => ({ title: `Pozvánka do "${p.trip}"`, body: `${p.actor} pozval ${p.invitee || 'člena'} na výlet "${p.trip}".` }),
@@ -203,6 +214,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nová zpráva v "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Balení: ${p.category}`, body: `${p.actor} vás přiřadil do kategorie "${p.category}" v "${p.trip}".` }),
version_available: p => ({ title: 'Nová verze TREK dostupná', body: `TREK ${p.version} je nyní dostupný. Navštivte administrátorský panel pro aktualizaci.` }),
synology_session_cleared: () => ({ title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.' }),
},
hu: {
trip_invite: p => ({ title: `Meghívó a(z) "${p.trip}" utazásra`, body: `${p.actor} meghívta ${p.invitee || 'egy tagot'} a(z) "${p.trip}" utazásra.` }),
@@ -213,6 +225,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Új üzenet a(z) "${p.trip}" utazásban`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Csomagolás: ${p.category}`, body: `${p.actor} hozzárendelte Önt a "${p.category}" csomagolási kategóriához a(z) "${p.trip}" utazásban.` }),
version_available: p => ({ title: 'Új TREK verzió érhető el', body: `A TREK ${p.version} elérhető. Látogasson el az adminisztrációs panelre a frissítéshez.` }),
synology_session_cleared: () => ({ title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.' }),
},
it: {
trip_invite: p => ({ title: `Invito a "${p.trip}"`, body: `${p.actor} ha invitato ${p.invitee || 'un membro'} al viaggio "${p.trip}".` }),
@@ -223,6 +236,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nuovo messaggio in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Bagagli: ${p.category}`, body: `${p.actor} ti ha assegnato alla categoria "${p.category}" in "${p.trip}".` }),
version_available: p => ({ title: 'Nuova versione TREK disponibile', body: `TREK ${p.version} è ora disponibile. Visita il pannello di amministrazione per aggiornare.` }),
synology_session_cleared: () => ({ title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.' }),
},
pl: {
trip_invite: p => ({ title: `Zaproszenie do "${p.trip}"`, body: `${p.actor} zaprosił ${p.invitee || 'członka'} do podróży "${p.trip}".` }),
@@ -233,6 +247,7 @@ const EVENT_TEXTS: Record<string, Record<NotifEventType, EventTextFn>> = {
collab_message: p => ({ title: `Nowa wiadomość w "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Pakowanie: ${p.category}`, body: `${p.actor} przypisał Cię do kategorii "${p.category}" w "${p.trip}".` }),
version_available: p => ({ title: 'Nowa wersja TREK dostępna', body: `TREK ${p.version} jest teraz dostępny. Odwiedź panel administracyjny, aby zaktualizować.` }),
synology_session_cleared: () => ({ title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.' }),
},
};
+638
View File
@@ -0,0 +1,638 @@
import crypto, { randomBytes, createHash, randomUUID } from 'crypto';
import { db } from '../db/database';
import { isAddonEnabled } from './adminService';
import { validateScopes } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import { User } from '../types';
import { writeAudit, logWarn } from './auditLog';
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_TTL_S = 60 * 60; // 1 hour
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days rolling
const AUTH_CODE_TTL_MS = 2 * 60 * 1000; // 2 minutes
// PKCE format (RFC 7636)
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43}$/;
const CODE_VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
// ---------------------------------------------------------------------------
// In-memory auth code store (short-lived, no need for DB persistence)
// ---------------------------------------------------------------------------
interface PendingCode {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
codeChallenge: string;
codeChallengeMethod: 'S256';
expiresAt: number;
}
const MAX_PENDING_CODES = 500;
const pendingCodes = new Map<string, PendingCode>();
setInterval(() => {
const now = Date.now();
for (const [key, entry] of pendingCodes) {
if (now > entry.expiresAt) pendingCodes.delete(key);
}
}, 60_000).unref();
// ---------------------------------------------------------------------------
// DB row types
// ---------------------------------------------------------------------------
interface OAuthClientRow {
id: string;
user_id: number;
name: string;
client_id: string;
client_secret_hash: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
created_at: string;
is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration'
}
interface OAuthTokenRow {
id: number;
client_id: string;
user_id: number;
access_token_hash: string;
refresh_token_hash: string;
scopes: string; // JSON array
access_token_expires_at: string;
refresh_token_expires_at: string;
revoked_at: string | null;
parent_token_id: number | null;
}
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
/** Constant-time comparison of two hex-encoded SHA-256 hashes. */
function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch { return false; }
}
function generateAccessToken(): string {
return 'trekoa_' + randomBytes(32).toString('hex');
}
function generateRefreshToken(): string {
return 'trekrf_' + randomBytes(32).toString('hex');
}
// ---------------------------------------------------------------------------
// Client management (self-service, gated by MCP addon)
// ---------------------------------------------------------------------------
export function listOAuthClients(userId: number): Record<string, unknown>[] {
const rows = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[];
return rows.map(r => ({
...r,
is_public: Boolean(r.is_public),
redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes),
}));
}
/** Returns true if the URI is a valid OAuth redirect target (HTTPS or localhost). */
export function isValidRedirectUri(uri: string): boolean {
try {
const url = new URL(uri);
return url.protocol === 'https:' || url.hostname === 'localhost' || url.hostname === '127.0.0.1';
} catch {
return false;
}
}
export function createOAuthClient(
userId: number | null,
name: string,
redirectUris: string[],
allowedScopes: string[],
ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string },
): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) {
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return { error: `Invalid redirect URI: ${uri}`, status: 400 };
}
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
return { error: `Redirect URI must use HTTPS (localhost exempt): ${uri}`, status: 400 };
}
}
if (!allowedScopes || allowedScopes.length === 0) return { error: 'At least one scope is required', status: 400 };
const { valid, invalid } = validateScopes(allowedScopes);
if (!valid) return { error: `Invalid scopes: ${invalid.join(', ')}`, status: 400 };
if (userId !== null) {
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id = ?').get(userId) as { count: number }).count;
if (count >= 10) return { error: 'Maximum of 10 OAuth clients per user', status: 400 };
} else {
// Anonymous DCR clients: enforce a global cap to prevent unbounded registration abuse
const count = (db.prepare('SELECT COUNT(*) as count FROM oauth_clients WHERE user_id IS NULL').get() as { count: number }).count;
if (count >= 500) return { error: 'server_error', status: 503 };
}
const isPublic = options?.isPublic ?? false;
const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID();
const clientId = randomUUID();
// Public clients have no usable secret; store an opaque random value to satisfy NOT NULL.
const rawSecret = isPublic ? null : 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
db.prepare(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
const row = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow;
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
return {
client: {
id: row.id,
user_id: row.user_id,
name: row.name,
client_id: row.client_id,
redirect_uris: JSON.parse(row.redirect_uris),
allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at,
is_public: Boolean(row.is_public),
created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}),
},
};
}
export function rotateOAuthClientSecret(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; client_secret?: string } {
const row = db.prepare('SELECT id, client_id, is_public FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
if (row.is_public) return { error: 'Public clients do not use a client secret', status: 400 };
const rawSecret = 'trekcs_' + randomBytes(24).toString('hex');
const secretHash = hashToken(rawSecret);
db.prepare('UPDATE oauth_clients SET client_secret_hash = ? WHERE id = ?').run(secretHash, clientRowId);
// Revoke all existing tokens for this client so old sessions are invalidated
db.prepare("UPDATE oauth_tokens SET revoked_at = datetime('now') WHERE client_id = ? AND revoked_at IS NULL").run(row.client_id);
// Terminate active MCP sessions for this (user, client) pair
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.client.rotate_secret', details: { client_id: row.client_id }, ip });
return { client_secret: rawSecret };
}
export function deleteOAuthClient(
userId: number,
clientRowId: string,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id, client_id FROM oauth_clients WHERE id = ? AND user_id = ?').get(clientRowId, userId) as OAuthClientRow | undefined;
if (!row) return { error: 'Client not found', status: 404 };
db.prepare('DELETE FROM oauth_clients WHERE id = ?').run(clientRowId);
writeAudit({ userId, action: 'oauth.client.delete', details: { client_id: row.client_id }, ip });
return { success: true };
}
// ---------------------------------------------------------------------------
// Auth code (in-memory, 2-minute TTL)
// ---------------------------------------------------------------------------
export function createAuthCode(params: {
clientId: string;
userId: number;
redirectUri: string;
scopes: string[];
codeChallenge: string;
codeChallengeMethod: 'S256';
}): string | null {
if (pendingCodes.size >= MAX_PENDING_CODES) return null;
const rawCode = randomBytes(32).toString('hex');
pendingCodes.set(rawCode, { ...params, expiresAt: Date.now() + AUTH_CODE_TTL_MS });
return rawCode;
}
export function consumeAuthCode(code: string): PendingCode | null {
const entry = pendingCodes.get(code);
if (!entry) return null;
pendingCodes.delete(code);
if (Date.now() > entry.expiresAt) return null;
return entry;
}
// ---------------------------------------------------------------------------
// Consent management
// ---------------------------------------------------------------------------
export function getConsent(clientId: string, userId: number): string[] | null {
const row = db.prepare(
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?'
).get(clientId, userId) as { scopes: string } | undefined;
return row ? JSON.parse(row.scopes) : null;
}
export function saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): void {
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
const existing = getConsent(clientId, userId) ?? [];
const merged = Array.from(new Set([...existing, ...scopes]));
db.prepare(
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
).run(clientId, userId, JSON.stringify(merged));
writeAudit({ userId, action: 'oauth.consent.grant', details: { client_id: clientId, scopes: merged }, ip });
}
export function isConsentSufficient(existingScopes: string[], requestedScopes: string[]): boolean {
return requestedScopes.every(s => existingScopes.includes(s));
}
// ---------------------------------------------------------------------------
// Token issuance
// ---------------------------------------------------------------------------
export function issueTokens(
clientId: string,
userId: number,
scopes: string[],
parentTokenId: number | null = null,
): {
access_token: string;
refresh_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const rawRefresh = generateRefreshToken();
const accessHash = hashToken(rawAccess);
const refreshHash = hashToken(rawRefresh);
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
const refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_TTL_MS);
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, refreshHash, JSON.stringify(scopes), accessExpiry.toISOString(), refreshExpiry.toISOString(), parentTokenId);
return {
access_token: rawAccess,
refresh_token: rawRefresh,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request)
// ---------------------------------------------------------------------------
export interface OAuthTokenInfo {
user: User;
scopes: string[];
clientId: string;
}
export function getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
const hash = hashToken(rawToken);
const row = db.prepare(`
SELECT ot.scopes, ot.revoked_at, ot.access_token_expires_at,
ot.user_id, ot.client_id, u.username, u.email, u.role
FROM oauth_tokens ot
JOIN users u ON ot.user_id = u.id
WHERE ot.access_token_hash = ?
`).get(hash) as (OAuthTokenRow & { username: string; email: string; role: string }) | undefined;
if (!row) return null;
if (row.revoked_at) return null;
if (new Date(row.access_token_expires_at) < new Date()) return null;
return {
user: { id: row.user_id, username: row.username, email: row.email, role: row.role as 'admin' | 'user' },
scopes: JSON.parse(row.scopes),
clientId: row.client_id,
};
}
// ---------------------------------------------------------------------------
// Token refresh (rotation + replay detection)
// ---------------------------------------------------------------------------
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
function findChainRoot(tokenId: number): number {
let current = tokenId;
for (let i = 0; i < 100; i++) {
const row = db.prepare('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?').get(current) as { id: number; parent_token_id: number | null } | undefined;
if (!row || row.parent_token_id === null) return current;
current = row.parent_token_id;
}
return current;
}
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
function revokeChain(rootId: number): number[] {
const rows = db.prepare(`
WITH RECURSIVE chain(id) AS (
SELECT id FROM oauth_tokens WHERE id = ?
UNION ALL
SELECT t.id FROM oauth_tokens t JOIN chain c ON t.parent_token_id = c.id
)
SELECT id FROM chain
`).all(rootId) as { id: number }[];
const ids = rows.map(r => r.id);
if (ids.length > 0) {
db.prepare(
`UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id IN (${ids.map(() => '?').join(',')}) AND revoked_at IS NULL`
).run(...ids);
}
return ids;
}
export function refreshTokens(
rawRefreshToken: string,
clientId: string,
clientSecret: string | undefined,
ip?: string | null,
): { error?: string; status?: number; tokens?: ReturnType<typeof issueTokens> } {
const client = db.prepare('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return { error: 'invalid_client', status: 401 };
if (!client.is_public) {
if (!clientSecret || !timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) {
return { error: 'invalid_client', status: 401 };
}
}
const hash = hashToken(rawRefreshToken);
const row = db.prepare(`
SELECT id, client_id, user_id, scopes, refresh_token_expires_at, revoked_at, parent_token_id
FROM oauth_tokens WHERE refresh_token_hash = ?
`).get(hash) as OAuthTokenRow | undefined;
if (!row) return { error: 'invalid_grant', status: 400 };
if (row.client_id !== clientId) return { error: 'invalid_grant', status: 400 };
// ---- Replay detection (C3) ----
if (row.revoked_at) {
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
const rootId = findChainRoot(row.id);
revokeChain(rootId);
revokeUserSessionsForClient(row.user_id, clientId);
writeAudit({
userId: row.user_id,
action: 'oauth.token.replay_detected',
details: { client_id: clientId },
ip,
});
logWarn(`[OAuth] Refresh token replay detected for user=${row.user_id} client=${clientId} ip=${ip ?? '-'}`);
return { error: 'invalid_grant', status: 400 };
}
if (new Date(row.refresh_token_expires_at) < new Date()) return { error: 'invalid_grant', status: 400 };
// Revoke old pair immediately (rotation) and issue new pair linked to old row
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(row.id);
// Terminate active MCP sessions for the old token's client so client must re-authenticate
revokeUserSessionsForClient(row.user_id, clientId);
const tokens = issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id);
writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
return { tokens };
}
// ---------------------------------------------------------------------------
// Token revocation
// ---------------------------------------------------------------------------
export function revokeToken(rawToken: string, clientId: string, userId?: number, ip?: string | null): void {
const hash = hashToken(rawToken);
// Get the user_id for the token so we can revoke its MCP sessions
const row = db.prepare(
'SELECT user_id FROM oauth_tokens WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?'
).get(hash, hash, clientId) as { user_id: number } | undefined;
db.prepare(`
UPDATE oauth_tokens
SET revoked_at = CURRENT_TIMESTAMP
WHERE (access_token_hash = ? OR refresh_token_hash = ?) AND client_id = ?
`).run(hash, hash, clientId);
const affectedUserId = row?.user_id ?? userId;
if (affectedUserId) {
revokeUserSessionsForClient(affectedUserId, clientId);
writeAudit({ userId: affectedUserId, action: 'oauth.token.revoke', details: { client_id: clientId, method: 'token' }, ip });
}
}
// ---------------------------------------------------------------------------
// Active session listing (for user settings page)
// ---------------------------------------------------------------------------
export function listOAuthSessions(userId: number): Record<string, unknown>[] {
const rows = db.prepare(`
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
FROM oauth_tokens ot
JOIN oauth_clients oc ON ot.client_id = oc.client_id
WHERE ot.user_id = ?
AND ot.revoked_at IS NULL
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
ORDER BY ot.created_at DESC
`).all(userId) as Record<string, unknown>[];
return rows.map(r => ({ ...r, scopes: JSON.parse(r.scopes as string) }));
}
export function revokeSession(
userId: number,
sessionId: number,
ip?: string | null,
): { error?: string; status?: number; success?: boolean } {
const row = db.prepare('SELECT id, client_id FROM oauth_tokens WHERE id = ? AND user_id = ?').get(sessionId, userId) as { id: number; client_id: string } | undefined;
if (!row) return { error: 'Session not found', status: 404 };
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(sessionId);
revokeUserSessionsForClient(userId, row.client_id);
writeAudit({ userId, action: 'oauth.token.revoke', details: { client_id: row.client_id, method: 'session' }, ip });
return { success: true };
}
// ---------------------------------------------------------------------------
// Authorize request validation (option A: called by SPA via GET /api/oauth/authorize/validate)
// ---------------------------------------------------------------------------
export interface AuthorizeParams {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state?: string;
code_challenge: string;
code_challenge_method: string;
}
export interface ValidateAuthorizeResult {
valid: boolean;
error?: string;
error_description?: string;
client?: { name: string; allowed_scopes: string[] };
scopes?: string[];
/** true when user is logged in but consent UI must be shown */
consentRequired?: boolean;
/** true when the request is valid but user is not authenticated */
loginRequired?: boolean;
/** true when the client was registered via machine DCR — user may adjust scopes on the consent screen */
scopeSelectable?: boolean;
}
export function validateAuthorizeRequest(
params: AuthorizeParams,
userId: number | null,
): ValidateAuthorizeResult {
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return { valid: false, error: 'mcp_disabled', error_description: 'MCP is not enabled on this server' };
}
if (params.response_type !== 'code') {
return { valid: false, error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' };
}
if (!params.code_challenge || params.code_challenge_method !== 'S256') {
return { valid: false, error: 'invalid_request', error_description: 'PKCE with code_challenge_method=S256 is required (OAuth 2.1)' };
}
// H1: Enforce code_challenge format (RFC 7636 §4.2)
if (!CODE_CHALLENGE_RE.test(params.code_challenge)) {
return { valid: false, error: 'invalid_request', error_description: 'code_challenge must be 43 base64url characters (S256)' };
}
if (!params.client_id) {
return { valid: false, error: 'invalid_request', error_description: 'client_id is required' };
}
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(params.client_id) as OAuthClientRow | undefined;
if (!client) {
return { valid: false, error: 'invalid_client', error_description: 'Unknown client_id' };
}
const allowedUris: string[] = JSON.parse(client.redirect_uris);
if (!params.redirect_uri || !allowedUris.includes(params.redirect_uri)) {
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
}
const requestedScopes = (params.scope || '').split(' ').filter(Boolean);
if (requestedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'At least one scope is required' };
}
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
// Narrow to the intersection: drop scopes the client isn't permitted for rather
// than rejecting the whole request (per OAuth 2.0 §3.3 scope narrowing).
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
if (grantedScopes.length === 0) {
return { valid: false, error: 'invalid_scope', error_description: 'None of the requested scopes are permitted for this client' };
}
if (userId === null) {
// H3: return only the minimum required fields — do NOT expose scopes, client.name, or
// allowed_scopes to unauthenticated callers to prevent client enumeration.
return { valid: true, loginRequired: true };
}
const existingConsent = getConsent(params.client_id, userId);
const consentRequired = !existingConsent || !isConsentSufficient(existingConsent, grantedScopes);
return {
valid: true,
client: { name: client.name, allowed_scopes: allowedScopes },
scopes: grantedScopes,
consentRequired,
scopeSelectable: client.created_via === 'dcr',
};
}
// ---------------------------------------------------------------------------
// PKCE verification
// ---------------------------------------------------------------------------
export function verifyPKCE(codeVerifier: string, codeChallenge: string): boolean {
// H1: validate code_verifier format before hashing
if (!CODE_VERIFIER_RE.test(codeVerifier)) return false;
const expected = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
// Constant-time compare (both are base64url strings of equal length for S256)
if (expected.length !== codeChallenge.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(codeChallenge));
} catch { return false; }
}
// ---------------------------------------------------------------------------
// Client authentication (for token endpoint)
// ---------------------------------------------------------------------------
export function authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
const client = db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as OAuthClientRow | undefined;
if (!client) return null;
if (client.is_public) {
// Public clients are identified by client_id alone — PKCE provides the security guarantee.
return client;
}
// H4: constant-time comparison to prevent timing side-channel
if (!clientSecret) return null;
if (!timingSafeEqualHex(hashToken(clientSecret), client.client_secret_hash)) return null;
return client;
}
+3 -4
View File
@@ -4,6 +4,7 @@ import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { User } from '../types';
import { decrypt_api_key } from './apiKeyCrypto';
import { resolveAuthToggles } from './authService';
// ---------------------------------------------------------------------------
// Types
@@ -269,10 +270,8 @@ export function findOrCreateUser(
}
if (!isFirstUser && !validInvite) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as
| { value: string }
| undefined;
if (setting?.value === 'false') {
const { oidc_registration } = resolveAuthToggles();
if (!oidc_registration) {
return { error: 'registration_disabled' };
}
}
+16 -3
View File
@@ -374,14 +374,27 @@ export async function importGoogleList(tripId: string, url: string) {
return { error: 'No places with coordinates found in list', status: 400 };
}
// Insert places into trip
// Skip places that already exist in this trip (same name + coordinates within ~10m)
const existingPlaces = db.prepare(
'SELECT name, lat, lng FROM places WHERE trip_id = ?'
).all(tripId) as { name: string; lat: number; lng: number }[];
const isDuplicate = (p: { name: string; lat: number; lng: number }) =>
existingPlaces.some(e =>
e.name === p.name && Math.abs(e.lat - p.lat) < 0.0001 && Math.abs(e.lng - p.lng) < 0.0001
);
const newPlaces = places.filter(p => !isDuplicate(p));
const skipped = places.length - newPlaces.length;
// Insert only new places into trip
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const p of places) {
for (const p of newPlaces) {
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
@@ -389,7 +402,7 @@ export async function importGoogleList(tripId: string, url: string) {
});
insertAll();
return { places: created, listName };
return { places: created, listName, skipped };
}
// ---------------------------------------------------------------------------
+12
View File
@@ -259,6 +259,18 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
ownerEmail = (db.prepare('SELECT email FROM users WHERE id = ?').get(trip.user_id) as { email: string } | undefined)?.email;
}
// Clean up journey entries synced from this trip before deleting
// Delete skeleton entries (unfilled synced places)
db.prepare(`
DELETE FROM journey_entries
WHERE source_trip_id = ? AND type = 'skeleton'
`).run(tripId);
// Detach filled entries (keep user's written content, just remove trip link)
db.prepare(`
UPDATE journey_entries SET source_trip_id = NULL, source_place_id = NULL
WHERE source_trip_id = ?
`).run(tripId);
db.prepare('DELETE FROM trips WHERE id = ?').run(tripId);
return { tripId: Number(tripId), title: trip.title, ownerId: trip.user_id, isAdminDelete, ownerEmail };
+3 -1
View File
@@ -185,10 +185,11 @@ export interface UpdatePlanBody {
company_holidays_enabled?: boolean;
carry_over_enabled?: boolean;
weekend_days?: string;
week_start?: number;
}
export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: string | undefined) {
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days } = body;
const { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled, weekend_days, week_start } = body;
const updates: string[] = [];
const params: (string | number)[] = [];
@@ -198,6 +199,7 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId:
if (company_holidays_enabled !== undefined) { updates.push('company_holidays_enabled = ?'); params.push(company_holidays_enabled ? 1 : 0); }
if (carry_over_enabled !== undefined) { updates.push('carry_over_enabled = ?'); params.push(carry_over_enabled ? 1 : 0); }
if (weekend_days !== undefined) { updates.push('weekend_days = ?'); params.push(String(weekend_days)); }
if (week_start !== undefined) { updates.push('week_start = ?'); params.push(week_start === 0 ? 0 : 1); }
if (updates.length > 0) {
params.push(planId);