Merge branch 'dev' into dev
This commit is contained in:
@@ -472,6 +472,25 @@ function runMigrations(db: Database.Database): void {
|
||||
db.prepare('UPDATE users SET immich_api_key = ? WHERE id = ?').run(encrypt_api_key(row.immich_api_key), row.id);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {}
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trip_album_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
immich_album_id TEXT NOT NULL,
|
||||
album_name TEXT NOT NULL DEFAULT '',
|
||||
sync_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(trip_id, user_id, immich_album_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -132,7 +132,7 @@ import { authenticate } from './middleware/auth';
|
||||
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
||||
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
||||
|
||||
// Serve uploaded photos (public — needed for shared trips)
|
||||
// Serve uploaded photos — require auth token or valid share token
|
||||
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
const safeName = path.basename(req.params.filename);
|
||||
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
||||
@@ -141,6 +141,20 @@ app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
||||
return res.status(403).send('Forbidden');
|
||||
}
|
||||
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
||||
|
||||
// Allow if authenticated or if a valid share token is present
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = req.query.token as string || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
||||
if (!token) return res.status(401).send('Authentication required');
|
||||
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
jwt.verify(token, process.env.JWT_SECRET || require('./config').JWT_SECRET);
|
||||
} catch {
|
||||
// Check if it's a share token
|
||||
const shareRow = db.prepare('SELECT id FROM share_tokens WHERE token = ?').get(token);
|
||||
if (!shareRow) return res.status(401).send('Authentication required');
|
||||
}
|
||||
res.sendFile(resolved);
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ router.get('/summary/per-person', authenticate, (req: Request, res: Response) =>
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { category, name, total_price, persons, days, note } = req.body;
|
||||
const { category, name, total_price, persons, days, note, expense_date } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
@@ -93,7 +93,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
category || 'Other',
|
||||
@@ -102,7 +102,8 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
persons != null ? persons : null,
|
||||
days !== undefined && days !== null ? days : null,
|
||||
note || null,
|
||||
sortOrder
|
||||
sortOrder,
|
||||
expense_date || null
|
||||
);
|
||||
|
||||
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] };
|
||||
@@ -114,7 +115,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { category, name, total_price, persons, days, note, sort_order } = req.body;
|
||||
const { category, name, total_price, persons, days, note, sort_order, expense_date } = req.body;
|
||||
|
||||
const trip = verifyTripOwnership(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
@@ -133,7 +134,8 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END,
|
||||
days = CASE WHEN ? THEN ? ELSE days END,
|
||||
note = CASE WHEN ? THEN ? ELSE note END,
|
||||
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END
|
||||
sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END,
|
||||
expense_date = CASE WHEN ? THEN ? ELSE expense_date END
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
category || null,
|
||||
@@ -143,6 +145,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
days !== undefined ? 1 : 0, days !== undefined ? days : null,
|
||||
note !== undefined ? 1 : 0, note !== undefined ? note : null,
|
||||
sort_order !== undefined ? 1 : null, sort_order !== undefined ? sort_order : 0,
|
||||
expense_date !== undefined ? 1 : 0, expense_date !== undefined ? (expense_date || null) : null,
|
||||
id
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import { db } from '../db/database';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcast } from '../websocket';
|
||||
import { AuthRequest } from '../types';
|
||||
@@ -88,6 +88,24 @@ router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Test connection with provided credentials (without saving)
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
||||
if (!isValidImmichUrl(immich_url)) return res.json({ connected: false, error: 'Invalid Immich URL' });
|
||||
try {
|
||||
const resp = await fetch(`${immich_url}/api/users/me`, {
|
||||
headers: { 'x-api-key': immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` });
|
||||
const data = await resp.json() as { name?: string; email?: string };
|
||||
res.json({ connected: true, user: { name: data.name, email: data.email } });
|
||||
} catch (err: unknown) {
|
||||
res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Browse Immich Library (for photo picker) ────────────────────────────────
|
||||
|
||||
router.get('/browse', authenticate, async (req: Request, res: Response) => {
|
||||
@@ -161,6 +179,7 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
|
||||
router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const photos = db.prepare(`
|
||||
SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at,
|
||||
@@ -179,6 +198,7 @@ router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
||||
router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { asset_ids, shared = true } = req.body;
|
||||
|
||||
if (!Array.isArray(asset_ids) || asset_ids.length === 0) {
|
||||
@@ -209,6 +229,7 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
|
||||
// Remove a photo from a trip (own photos only)
|
||||
router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(req.params.tripId, authReq.user.id, req.params.assetId);
|
||||
res.json({ success: true });
|
||||
@@ -218,6 +239,7 @@ router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res
|
||||
// Toggle sharing for a specific photo
|
||||
router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { shared } = req.body;
|
||||
db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?')
|
||||
.run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId);
|
||||
@@ -331,4 +353,113 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res:
|
||||
}
|
||||
});
|
||||
|
||||
// ── Album Linking ──────────────────────────────────────────────────────────
|
||||
|
||||
// List user's Immich albums
|
||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/albums`, {
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' });
|
||||
const albums = (await resp.json() as any[]).map((a: any) => ({
|
||||
id: a.id,
|
||||
albumName: a.albumName,
|
||||
assetCount: a.assetCount || 0,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
albumThumbnailAssetId: a.albumThumbnailAssetId,
|
||||
}));
|
||||
res.json({ albums });
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach Immich' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get album links for a trip
|
||||
router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!canAccessTrip(req.params.tripId, (authReq as AuthRequest).user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const links = db.prepare(`
|
||||
SELECT tal.*, u.username
|
||||
FROM trip_album_links tal
|
||||
JOIN users u ON tal.user_id = u.id
|
||||
WHERE tal.trip_id = ?
|
||||
ORDER BY tal.created_at ASC
|
||||
`).all(req.params.tripId);
|
||||
res.json({ links });
|
||||
});
|
||||
|
||||
// Link an album to a trip
|
||||
router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
const { album_id, album_name } = req.body;
|
||||
if (!album_id) return res.status(400).json({ error: 'album_id required' });
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)'
|
||||
).run(tripId, authReq.user.id, album_id, album_name || '');
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: 'Album already linked' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove album link
|
||||
router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.run(req.params.linkId, req.params.tripId, authReq.user.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Sync album — fetch all assets from Immich album and add missing ones to trip
|
||||
router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, linkId } = req.params;
|
||||
|
||||
const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?')
|
||||
.get(linkId, tripId, authReq.user.id) as any;
|
||||
if (!link) return res.status(404).json({ error: 'Album link not found' });
|
||||
|
||||
const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any;
|
||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, {
|
||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' });
|
||||
const albumData = await resp.json() as { assets?: any[] };
|
||||
const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE');
|
||||
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)'
|
||||
);
|
||||
let added = 0;
|
||||
for (const asset of assets) {
|
||||
const r = insert.run(tripId, authReq.user.id, asset.id);
|
||||
if (r.changes > 0) added++;
|
||||
}
|
||||
|
||||
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||
|
||||
res.json({ success: true, added, total: assets.length });
|
||||
if (added > 0) {
|
||||
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach Immich' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -154,7 +154,7 @@ async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Pro
|
||||
ggslimit: '5',
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url|extmetadata|mime',
|
||||
iiurlwidth: '600',
|
||||
iiurlwidth: '400',
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
|
||||
@@ -380,11 +380,14 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
const { placeId } = req.params;
|
||||
|
||||
const cached = photoCache.get(placeId);
|
||||
if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) {
|
||||
if (cached.error) {
|
||||
return res.status(404).json({ error: `(Cache) No photo available` });
|
||||
const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors
|
||||
if (cached) {
|
||||
const ttl = cached.error ? ERROR_TTL : PHOTO_TTL;
|
||||
if (Date.now() - cached.fetchedAt < ttl) {
|
||||
if (cached.error) return res.status(404).json({ error: `(Cache) No photo available` });
|
||||
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
|
||||
}
|
||||
return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution });
|
||||
photoCache.delete(placeId);
|
||||
}
|
||||
|
||||
// Wikimedia Commons fallback for OSM places (using lat/lng query params)
|
||||
@@ -436,7 +439,7 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
const mediaRes = await fetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&skipHttpRedirect=true`,
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
const mediaData = await mediaRes.json() as { photoUri?: string };
|
||||
|
||||
Reference in New Issue
Block a user