remove /media endpoint
This commit is contained in:
+37
-33
@@ -2,12 +2,12 @@ import { Router } from 'express';
|
||||
import { upload } from '../middleware/upload.js';
|
||||
import { config } from '../config.js';
|
||||
import {
|
||||
listRoutes, getRoute, createRoute, updateRoute, appendRoute,
|
||||
listRoutes, getRoute, getRouteGpxFile, createRoute, updateRoute, appendRoute,
|
||||
listPois, getPoi, createPoi, updatePoi, softDeleteRoute, restoreRoute
|
||||
} from '../services/routes-service.js';
|
||||
import {
|
||||
listPictures, getPictureContent, createPicture, updatePicture, deletePicture,
|
||||
listAudio, getAudioContent, createAudio, updateAudio, deleteAudio
|
||||
listPictures, getPicture, getPictureFile, createPicture, updatePicture, deletePicture,
|
||||
listAudio, getAudio, getAudioFile, createAudio, updateAudio, deleteAudio
|
||||
} from '../services/media-service.js';
|
||||
|
||||
const numberOrUndefined = value => {
|
||||
@@ -16,24 +16,16 @@ const numberOrUndefined = value => {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const positiveInteger = (value, name) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
const error = new Error(`${name} muss eine positive Ganzzahl sein.`);
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
function sendMedia(res, resource) {
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
'Content-Disposition': `inline; filename="${resource.filename.replace(/["\\]/g, '_')}"`,
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
function sendFileResource(res, file, contentType = null) {
|
||||
if (contentType) res.type(contentType);
|
||||
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.filename)}`);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return res.sendFile(file.absolutePath, {
|
||||
acceptRanges: true,
|
||||
cacheControl: true,
|
||||
immutable: false,
|
||||
maxAge: '1h'
|
||||
});
|
||||
res.type(resource.contentType);
|
||||
res.sendFile(resource.absolutePath);
|
||||
}
|
||||
|
||||
export function createApiRouter(db) {
|
||||
@@ -63,6 +55,10 @@ export function createApiRouter(db) {
|
||||
res.json(getRoute(db, Number(req.params.id), req.query.includeDeleted === 'true'));
|
||||
});
|
||||
|
||||
api.get('/routes/:id/gpx', (req, res) => {
|
||||
sendFileResource(res, getRouteGpxFile(db, Number(req.params.id)), 'application/gpx+xml');
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pois', (req, res) => {
|
||||
getRoute(db, Number(req.params.id));
|
||||
res.json({ pois: listPois(db, Number(req.params.id)) });
|
||||
@@ -99,7 +95,13 @@ export function createApiRouter(db) {
|
||||
});
|
||||
|
||||
api.get('/routes/:id/pictures/:pictureId', (req, res) => {
|
||||
sendMedia(res, getPictureContent(db, Number(req.params.id), Number(req.params.pictureId)));
|
||||
const routeId = Number(req.params.id);
|
||||
const pictureId = Number(req.params.pictureId);
|
||||
if (req.query.metadata === 'true') {
|
||||
res.json(getPicture(db, routeId, pictureId));
|
||||
return;
|
||||
}
|
||||
sendFileResource(res, getPictureFile(db, routeId, pictureId));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/pictures', upload.single('picture'), (req, res) => {
|
||||
@@ -118,30 +120,32 @@ export function createApiRouter(db) {
|
||||
});
|
||||
|
||||
api.get('/routes/:id/audio', (req, res) => {
|
||||
if (req.query.poiId == null || req.query.poiId === '') {
|
||||
res.json({ audio: listAudio(db, Number(req.params.id)) });
|
||||
res.json({ audio: listAudio(db, Number(req.params.id), { poiId: req.query.poiId }) });
|
||||
});
|
||||
|
||||
api.get('/routes/:id/audio/:poiId', (req, res) => {
|
||||
const routeId = Number(req.params.id);
|
||||
const poiId = Number(req.params.poiId);
|
||||
if (req.query.metadata === 'true') {
|
||||
res.json(getAudio(db, routeId, poiId));
|
||||
return;
|
||||
}
|
||||
|
||||
const poiId = positiveInteger(req.query.poiId, 'poiId');
|
||||
sendMedia(res, getAudioContent(db, Number(req.params.id), poiId));
|
||||
sendFileResource(res, getAudioFile(db, routeId, poiId));
|
||||
});
|
||||
|
||||
api.post('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||
const audio = createAudio(db, Number(req.params.id), req.body, req.file);
|
||||
res.status(201)
|
||||
.location(`/api/routes/${req.params.id}/audio?poiId=${encodeURIComponent(audio.poiId)}`)
|
||||
.location(`/api/routes/${req.params.id}/audio/${audio.poiId}`)
|
||||
.json(audio);
|
||||
});
|
||||
|
||||
api.put('/routes/:id/audio', upload.single('audio'), (req, res) => {
|
||||
const poiId = positiveInteger(req.query.poiId ?? req.body.poiId, 'poiId');
|
||||
res.json(updateAudio(db, Number(req.params.id), poiId, req.file));
|
||||
api.put('/routes/:id/audio/:poiId', upload.single('audio'), (req, res) => {
|
||||
res.json(updateAudio(db, Number(req.params.id), Number(req.params.poiId), req.file));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id/audio', (req, res) => {
|
||||
const poiId = positiveInteger(req.query.poiId ?? req.body?.poiId, 'poiId');
|
||||
res.json(deleteAudio(db, Number(req.params.id), poiId));
|
||||
api.delete('/routes/:id/audio/:poiId', (req, res) => {
|
||||
res.json(deleteAudio(db, Number(req.params.id), Number(req.params.poiId)));
|
||||
});
|
||||
|
||||
api.delete('/routes/:id', (req, res) => {
|
||||
|
||||
@@ -24,31 +24,8 @@ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
|
||||
const AUDIO_EXTENSIONS = new Set(['.mp3', '.mp4', '.m4a', '.aac', '.ogg', '.wav', '.webm']);
|
||||
|
||||
const uniqueFilename = original => `${crypto.randomUUID()}${path.extname(original).toLowerCase()}`;
|
||||
const pictureApiUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||
const audioApiUrl = (routeId, poiId) => `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poiId)}`;
|
||||
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'audio/mp4',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.aac': 'audio/aac',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.wav': 'audio/wav',
|
||||
'.webm': 'audio/webm'
|
||||
});
|
||||
|
||||
function contentResource(relativePath) {
|
||||
const absolutePath = resolveStoredFile(relativePath);
|
||||
return {
|
||||
absolutePath,
|
||||
contentType: CONTENT_TYPES[path.extname(absolutePath).toLowerCase()] || 'application/octet-stream',
|
||||
filename: path.basename(absolutePath)
|
||||
};
|
||||
}
|
||||
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`;
|
||||
|
||||
function requirePositiveInteger(value, name) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -108,7 +85,7 @@ function pictureFromRow(row) {
|
||||
poiTitle: row.poi_title,
|
||||
caption: row.caption,
|
||||
sequence: row.sequence,
|
||||
url: pictureApiUrl(row.route_id, row.id),
|
||||
url: pictureUrl(row.route_id, row.id),
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
@@ -137,11 +114,14 @@ export function getPicture(db, routeId, pictureId) {
|
||||
return pictureFromRow(row);
|
||||
}
|
||||
|
||||
export function getPictureContent(db, routeId, pictureId) {
|
||||
export function getPictureFile(db, routeId, pictureId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${pictureSelect} WHERE p.route_id = ? AND i.id = ? AND r.status = 'active'`).get(routeId, pictureId);
|
||||
if (!row) throw new HttpError(404, 'Bild auf dieser Strecke nicht gefunden.');
|
||||
return contentResource(row.path);
|
||||
return {
|
||||
absolutePath: resolveStoredFile(row.path),
|
||||
filename: path.basename(row.path)
|
||||
};
|
||||
}
|
||||
|
||||
export function createPicture(db, routeId, fields, file) {
|
||||
@@ -216,7 +196,7 @@ function audioFromRow(row) {
|
||||
routeId: row.route_id,
|
||||
poiId: row.poi_id,
|
||||
poiTitle: row.poi_title,
|
||||
url: audioApiUrl(row.route_id, row.poi_id),
|
||||
url: audioUrl(row.route_id, row.poi_id),
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
@@ -245,12 +225,15 @@ export function getAudio(db, routeId, poiId) {
|
||||
return audioFromRow(row);
|
||||
}
|
||||
|
||||
export function getAudioContent(db, routeId, poiId) {
|
||||
export function getAudioFile(db, routeId, poiId) {
|
||||
activeRoute(db, routeId);
|
||||
const row = db.prepare(`${audioSelect} WHERE p.route_id = ? AND p.id = ? AND p.audio_path IS NOT NULL AND r.status = 'active'`)
|
||||
.get(routeId, poiId);
|
||||
if (!row) throw new HttpError(404, 'Audiodatei für diesen POI nicht gefunden.');
|
||||
return contentResource(row.audio_path);
|
||||
return {
|
||||
absolutePath: resolveStoredFile(row.audio_path),
|
||||
filename: path.basename(row.audio_path)
|
||||
};
|
||||
}
|
||||
|
||||
function storeAudio(db, routeId, poiId, file, { requireAbsent, requireExisting }) {
|
||||
|
||||
@@ -7,10 +7,13 @@ import { parseGpx } from './gpx.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import {
|
||||
ensureRouteDirectories, moveUploadedFile, routeDirectory,
|
||||
softDeleteRouteDirectory, restoreRouteDirectory, safeMediaUrl, removeUpload
|
||||
softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload
|
||||
} from './storage.js';
|
||||
|
||||
const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
|
||||
const routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
|
||||
const pictureUrl = (routeId, pictureId) => `/api/routes/${routeId}/pictures/${pictureId}`;
|
||||
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/audio/${poiId}`;
|
||||
function rowToRoute(row, includeDeleted = false) {
|
||||
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
||||
return {
|
||||
@@ -20,7 +23,7 @@ function rowToRoute(row, includeDeleted = false) {
|
||||
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
|
||||
bounds: row.min_lat == null ? null : { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
|
||||
distanceM: row.distance_m, elevationGainM: row.elevation_gain_m, pointCount: row.point_count,
|
||||
gpxUrl: row.gpx_path ? safeMediaUrl(row.gpx_path) : null,
|
||||
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
@@ -43,6 +46,15 @@ export function getRoute(db, id, includeDeleted = false) {
|
||||
return { ...route, points, pois };
|
||||
}
|
||||
|
||||
export function getRouteGpxFile(db, id) {
|
||||
const route = db.prepare("SELECT id, slug, gpx_path FROM routes WHERE id = ? AND status = 'active'").get(id);
|
||||
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||
return {
|
||||
absolutePath: resolveStoredFile(route.gpx_path),
|
||||
filename: `${route.slug}.gpx`
|
||||
};
|
||||
}
|
||||
|
||||
export function createRoute(db, fields, gpxFile) {
|
||||
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
||||
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||
@@ -129,12 +141,12 @@ export function listPois(db, routeId) {
|
||||
return pois.map(poi => ({
|
||||
id: poi.id, routeId: poi.route_id, title: poi.title, description: poi.description,
|
||||
lat: poi.lat, lon: poi.lon, triggerRadiusM: poi.trigger_radius_m, sequence: poi.sequence,
|
||||
audioUrl: poi.audio_path ? `/api/routes/${routeId}/audio?poiId=${encodeURIComponent(poi.id)}` : null,
|
||||
audioUrl: poi.audio_path ? audioUrl(poi.route_id, poi.id) : null,
|
||||
images: imageStatement.all(poi.id).map(image => ({
|
||||
id: image.id,
|
||||
caption: image.caption,
|
||||
sequence: image.sequence,
|
||||
url: `/api/routes/${routeId}/pictures/${image.id}`
|
||||
url: pictureUrl(poi.route_id, image.id)
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
+14
-20
@@ -25,22 +25,6 @@ export function removeUpload(uploaded) {
|
||||
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
|
||||
}
|
||||
|
||||
export function resolveStoredFile(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) {
|
||||
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function removeStoredFile(relativePath) {
|
||||
if (!relativePath) return false;
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
@@ -70,9 +54,19 @@ export function restoreRouteDirectory(id, trashRelativePath) {
|
||||
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), source, destination };
|
||||
}
|
||||
|
||||
export function safeMediaUrl(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) return null;
|
||||
const publicPath = relativePath.slice('active/'.length);
|
||||
return `/media/${publicPath.split('/').map(encodeURIComponent).join('/')}`;
|
||||
export function resolveStoredFile(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) {
|
||||
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target === storageRoot || !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user