Bug fixes for video serving and updats to documentation for mobile use screenshots

This commit is contained in:
2026-04-30 14:17:50 -06:00
parent aba935c8ac
commit 2ae7d8b968
32 changed files with 247 additions and 238 deletions

View File

@@ -25,7 +25,12 @@ COPY tsconfig.json ./
# Development stage with hot reload
FROM base AS development
# su-exec for dropping privileges after fixing mounted volume permissions
RUN apk add --no-cache su-exec
RUN npm install tsx --save-dev
COPY media-docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/media-docker-entrypoint.sh
ENTRYPOINT ["media-docker-entrypoint.sh"]
CMD ["npx", "tsx", "watch", "src/media-server.ts"]
# Build stage
@@ -37,8 +42,9 @@ RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip && pip3 install --break-system-packages yt-dlp
# Install ffmpeg for video metadata, vips-dev for sharp HEIC support, yt-dlp for video fetching.
# su-exec lets the entrypoint drop privileges after fixing mounted volume permissions.
RUN apk add --no-cache ffmpeg vips-dev python3 py3-pip su-exec && pip3 install --break-system-packages yt-dlp
# Copy manifests and install production-only deps (no devDeps like typescript)
COPY --from=build /app/package*.json ./
@@ -50,7 +56,11 @@ COPY --from=build /app/dist ./dist
COPY --from=build /app/prisma ./prisma
RUN npx prisma generate
# Run as non-root user
USER node
# Entrypoint chowns mounted /media subdirs (root-owned on fresh deploys) then
# drops to node via su-exec. Note: USER node is intentionally NOT set here —
# the entrypoint must start as root to repair host bind-mount permissions.
COPY media-docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/media-docker-entrypoint.sh
ENTRYPOINT ["media-docker-entrypoint.sh"]
CMD ["node", "dist/media-server.js"]

22
api/media-docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -e
# Fix permissions for mounted volumes (host dirs may be root-owned on first
# run — Docker auto-creates missing bind-mount sources as root:root).
# /media's :ro parent mount cannot be chowned, but the :rw subdirs that
# media-api writes into can — and that's all the process needs.
# /app/logs is shared with the api container via the ./api:/app dev mount;
# api's entrypoint already chowns it but media-api may start independently.
if [ "$(id -u)" = "0" ]; then
for d in /media/local/inbox /media/local/thumbnails /media/local/photos \
/media/local/documents /media/public /app/logs; do
[ -d "$d" ] && chown -R node:node "$d" 2>/dev/null || true
done
fi
# Drop to node user if running as root (production image uses su-exec).
if [ "$(id -u)" = "0" ] && command -v su-exec >/dev/null 2>&1; then
exec su-exec node "$@"
else
exec "$@"
fi

View File

@@ -3,8 +3,8 @@ import { prisma } from '../../../config/database';
import { requireAdminRole } from '../middleware/auth';
import { videoAnalyticsService } from '../services/video-analytics.service';
import { logger } from '../../../utils/logger';
import { sign } from 'jsonwebtoken';
import { env } from '../../../config/env';
import { signMediaPath } from '../../../utils/signed-url';
import { copyFile } from 'fs/promises';
import { join, dirname, basename, extname, normalize, resolve } from 'path';
import { z } from 'zod';
@@ -299,7 +299,13 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
/**
* GET /videos/:id/preview-link
* Generate a temporary preview link with expiring JWT token
* Generate a shareable, time-limited preview link.
*
* Uses path-bound HMAC signatures (sig/exp/uid) — same scheme as
* POST /api/media/sign — instead of the legacy `?token=<JWT>` form,
* which leaked full session tokens via access logs/referer headers.
* The signature carries only the admin's user-id and is bound to the
* stream URL, so it can be safely shared with stakeholders.
*/
fastify.get<{ Params: { id: string } }>(
'/:id/preview-link',
@@ -318,24 +324,20 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
return reply.code(404).send({ message: 'Video not found' });
}
// Generate JWT token that expires in 24 hours
const expiryHours = parseInt(process.env.VIDEO_PREVIEW_LINK_EXPIRY_HOURS || '24');
const token = sign(
{
videoId,
purpose: 'preview',
},
env.JWT_ACCESS_SECRET,
{ expiresIn: `${expiryHours}h` }
);
const ttlSeconds = expiryHours * 60 * 60;
const userId = request.user!.id;
const previewUrl = `${env.MEDIA_API_PUBLIC_URL}/api/videos/${videoId}/preview?token=${token}`;
const streamPath = `/api/videos/${videoId}/stream`;
const signed = signMediaPath(streamPath, userId, ttlSeconds);
const query = `sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
const previewUrl = `${env.MEDIA_API_PUBLIC_URL}${streamPath}?${query}`;
logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
return {
previewUrl,
expiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000).toISOString(),
expiresAt: new Date(Number(signed.exp) * 1000).toISOString(),
expiryHours,
};
} catch (error) {