Compare commits
2 Commits
3ae9c03614
...
987eb210eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
987eb210eb | ||
|
|
8b94a25807 |
@@ -69,7 +69,7 @@ The built-in Actions job token can be disabled or lack registry scope on self-ho
|
|||||||
- `JWT_SECRET`: a unique random string of at least 32 characters.
|
- `JWT_SECRET`: a unique random string of at least 32 characters.
|
||||||
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
|
- `FRONTEND_ORIGIN`: the exact public application URL, for example `https://hub.example.com`.
|
||||||
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
|
- `SUPERADMIN_EMAIL`: email address for the initial platform administrator.
|
||||||
- `SUPERADMIN_PASSWORD`: password for that administrator (at least 12 characters).
|
- `SUPERADMIN_PASSWORD`: password for that administrator (at least 8 characters).
|
||||||
- `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` are optional login and public-auth throttling controls (defaults: 10 attempts per 900 seconds per source IP).
|
- `AUTH_RATE_LIMIT_MAX` and `AUTH_RATE_LIMIT_WINDOW_SECONDS` are optional login and public-auth throttling controls (defaults: 10 attempts per 900 seconds per source IP).
|
||||||
- `JWT_SESSION_TTL`, `INVITATION_TTL_HOURS`, `PASSWORD_RESET_TTL_HOURS`, `BUNNY_EMBED_TOKEN_TTL_SECONDS`, and `AUDIT_LOG_PAGE_SIZE` tune operating policy without changing code.
|
- `JWT_SESSION_TTL`, `INVITATION_TTL_HOURS`, `PASSWORD_RESET_TTL_HOURS`, `BUNNY_EMBED_TOKEN_TTL_SECONDS`, and `AUDIT_LOG_PAGE_SIZE` tune operating policy without changing code.
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
|||||||
|
|
||||||
if (!signedInUser) {
|
if (!signedInUser) {
|
||||||
setError(isRegistering
|
setError(isRegistering
|
||||||
? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 12 caracteres.'
|
? 'Não foi possível criar sua conta. Use um e-mail válido e uma senha com pelo menos 8 caracteres.'
|
||||||
: 'Credenciais inválidas. Verifique seu e-mail e senha.');
|
: 'Credenciais inválidas. Verifique seu e-mail e senha.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />}
|
{isRegistering && <input type="text" placeholder="Seu nome" value={name} onChange={(event) => setName(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus />}
|
||||||
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} />
|
<input type="email" placeholder="Seu e-mail" value={email} onChange={(event) => setEmail(event.target.value)} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required autoFocus={!isRegistering} />
|
||||||
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
|
<input type="password" placeholder="Sua senha" value={password} onChange={(event) => setPassword(event.target.value)} minLength={8} className="w-full bg-white/10 text-white placeholder:text-white/30 px-4 py-3 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:bg-white/15 transition-all text-body tracking-body" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
|
{error && <div className="bg-red-500/10 border border-red-500/20 rounded-[8px] p-3"><p className="text-red-400 text-caption font-semibold tracking-caption text-center">{error}</p></div>}
|
||||||
|
|||||||
@@ -20,5 +20,5 @@ export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }
|
|||||||
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
|
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input required minLength={12} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 12 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
|
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input required minLength={8} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 8 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
|
||||||
};
|
};
|
||||||
|
|||||||
16
server/migrations/014_bunny_video_ownership.sql
Normal file
16
server/migrations/014_bunny_video_ownership.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
create table if not exists bunny_video_uploads (
|
||||||
|
video_id text primary key,
|
||||||
|
owner_id uuid not null references users(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists bunny_video_uploads_owner_index on bunny_video_uploads (owner_id);
|
||||||
|
|
||||||
|
-- Preserve access to Bunny videos created before ownership was tracked.
|
||||||
|
insert into bunny_video_uploads (video_id, owner_id)
|
||||||
|
select distinct on (lm.external_id) lm.external_id, c.instructor_id
|
||||||
|
from lesson_media lm
|
||||||
|
join lessons l on l.id = lm.lesson_id
|
||||||
|
join courses c on c.id = l.course_id
|
||||||
|
where lm.provider = 'bunny'
|
||||||
|
on conflict (video_id) do nothing;
|
||||||
@@ -11,7 +11,7 @@ const environmentSchema = z.object({
|
|||||||
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
APP_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
|
JWT_SECRET: z.string().min(32).default('development-only-secret-change-before-production'),
|
||||||
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
|
SUPERADMIN_EMAIL: optionalEnvironmentValue(z.string().email()),
|
||||||
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(12)),
|
SUPERADMIN_PASSWORD: optionalEnvironmentValue(z.string().min(8)),
|
||||||
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
|
SUPERADMIN_NAME: z.string().min(1).max(120).default('Compor HUB Superadmin'),
|
||||||
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
|
AUTH_RATE_LIMIT_MAX: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
|
AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().int().min(60).max(86_400).default(900),
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { sendWelcomeEmail } from '../services/email.js';
|
|||||||
|
|
||||||
const credentialsSchema = z.object({
|
const credentialsSchema = z.object({
|
||||||
email: z.string().email().transform((email) => email.toLowerCase()),
|
email: z.string().email().transform((email) => email.toLowerCase()),
|
||||||
password: z.string().min(12).max(200),
|
password: z.string().min(8).max(200),
|
||||||
});
|
});
|
||||||
|
|
||||||
const registerSchema = credentialsSchema.extend({
|
const registerSchema = credentialsSchema.extend({
|
||||||
name: z.string().trim().min(2).max(120),
|
name: z.string().trim().min(2).max(120),
|
||||||
});
|
});
|
||||||
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() });
|
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(8).max(200), name: z.string().trim().min(2).max(120).optional() });
|
||||||
|
|
||||||
type UserRow = {
|
type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
uploadBunnyVideo,
|
uploadBunnyVideo,
|
||||||
} from '../providers/bunny.js';
|
} from '../providers/bunny.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { VideoUploadValidationError, validateVideoUpload } from '../uploads/video.js';
|
||||||
import {
|
import {
|
||||||
BunnyStorageConfigurationError,
|
BunnyStorageConfigurationError,
|
||||||
BunnyStorageRequestError,
|
BunnyStorageRequestError,
|
||||||
@@ -23,6 +24,7 @@ const createVideoSchema = z.object({ title: z.string().trim().min(1).max(255) })
|
|||||||
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
const videoParamsSchema = z.object({ videoId: z.string().uuid() });
|
||||||
|
|
||||||
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
function providerError(reply: { code: (status: number) => { send: (payload: object) => unknown } }, error: unknown) {
|
||||||
|
if (error instanceof VideoUploadValidationError) return reply.code(error.statusCode).send({ error: error.message });
|
||||||
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
|
if (error instanceof BunnyConfigurationError) return reply.code(503).send({ error: error.message });
|
||||||
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' });
|
if (error instanceof BunnyRequestError) return reply.code(502).send({ error: 'Bunny Stream could not complete this request. Please try again.' });
|
||||||
throw error;
|
throw error;
|
||||||
@@ -51,6 +53,23 @@ async function persistBunnyStatus(video: { id: string; status: string; durationS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertVideoAccess(videoId: string, user: { id: string; role: string }) {
|
||||||
|
if (user.role === 'admin') return;
|
||||||
|
const result = await pool.query<{ owner_id: string }>('select owner_id from bunny_video_uploads where video_id = $1', [videoId]);
|
||||||
|
if (result.rows[0]?.owner_id === user.id) return;
|
||||||
|
const legacy = await pool.query<{ instructor_id: string }>(
|
||||||
|
`select c.instructor_id
|
||||||
|
from lesson_media lm
|
||||||
|
join lessons l on l.id = lm.lesson_id
|
||||||
|
join courses c on c.id = l.course_id
|
||||||
|
where lm.provider = 'bunny' and lm.external_id = $1
|
||||||
|
limit 1`,
|
||||||
|
[videoId],
|
||||||
|
);
|
||||||
|
if (legacy.rows[0]?.instructor_id === user.id) return;
|
||||||
|
throw new VideoUploadValidationError('You do not have access to this video.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
|
const manageAccess = { preHandler: app.requireRoles(['instructor', 'admin']) };
|
||||||
|
|
||||||
@@ -89,6 +108,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const input = createVideoSchema.parse(request.body);
|
const input = createVideoSchema.parse(request.body);
|
||||||
try {
|
try {
|
||||||
const video = await createBunnyVideo(input.title);
|
const video = await createBunnyVideo(input.title);
|
||||||
|
await pool.query('insert into bunny_video_uploads (video_id, owner_id) values ($1, $2)', [video.id, request.user.id]);
|
||||||
await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'media.bunny.created', subjectType: 'video', subjectId: video.id, metadata: { title: video.title }, ipAddress: request.ip });
|
||||||
return reply.code(201).send({ data: video });
|
return reply.code(201).send({ data: video });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -98,6 +118,11 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
|
app.put('/bunny/videos/:videoId/upload', manageAccess, async (request, reply) => {
|
||||||
const { videoId } = videoParamsSchema.parse(request.params);
|
const { videoId } = videoParamsSchema.parse(request.params);
|
||||||
|
try {
|
||||||
|
await assertVideoAccess(videoId, request.user);
|
||||||
|
} catch (error) {
|
||||||
|
return providerError(reply, error);
|
||||||
|
}
|
||||||
const contentLength = Number(request.headers['content-length'] || 0);
|
const contentLength = Number(request.headers['content-length'] || 0);
|
||||||
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
|
const maxBytes = config.BUNNY_MAX_UPLOAD_MB * 1024 * 1024;
|
||||||
if (contentLength > maxBytes) {
|
if (contentLength > maxBytes) {
|
||||||
@@ -105,7 +130,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const video = await uploadBunnyVideo(videoId, request.body as ReadableStream, request.headers['content-type']);
|
const body = validateVideoUpload(request.body as import('node:stream').Readable, request.headers['content-type'], maxBytes);
|
||||||
|
const video = await uploadBunnyVideo(videoId, body as unknown as ReadableStream, request.headers['content-type']);
|
||||||
await persistBunnyStatus(video);
|
await persistBunnyStatus(video);
|
||||||
await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'media.bunny.uploaded', subjectType: 'video', subjectId: video.id, metadata: { status: video.status }, ipAddress: request.ip });
|
||||||
return { data: video };
|
return { data: video };
|
||||||
@@ -117,6 +143,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
app.get('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
||||||
const { videoId } = videoParamsSchema.parse(request.params);
|
const { videoId } = videoParamsSchema.parse(request.params);
|
||||||
try {
|
try {
|
||||||
|
await assertVideoAccess(videoId, request.user);
|
||||||
const video = await getBunnyVideo(videoId);
|
const video = await getBunnyVideo(videoId);
|
||||||
await persistBunnyStatus(video);
|
await persistBunnyStatus(video);
|
||||||
return { data: video };
|
return { data: video };
|
||||||
@@ -128,7 +155,9 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
app.delete('/bunny/videos/:videoId', manageAccess, async (request, reply) => {
|
||||||
const { videoId } = videoParamsSchema.parse(request.params);
|
const { videoId } = videoParamsSchema.parse(request.params);
|
||||||
try {
|
try {
|
||||||
|
await assertVideoAccess(videoId, request.user);
|
||||||
await deleteBunnyVideo(videoId);
|
await deleteBunnyVideo(videoId);
|
||||||
|
await pool.query('delete from bunny_video_uploads where video_id = $1', [videoId]);
|
||||||
await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip });
|
await recordAudit({ actorId: request.user.id, action: 'media.video.deleted', subjectType: 'video', subjectId: videoId, ipAddress: request.ip });
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
62
server/src/uploads/video.ts
Normal file
62
server/src/uploads/video.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Transform, type Readable } from 'node:stream';
|
||||||
|
|
||||||
|
const allowedContentTypes = new Set(['video/mp4', 'video/webm', 'video/quicktime']);
|
||||||
|
|
||||||
|
export class VideoUploadValidationError extends Error {
|
||||||
|
constructor(message: string, public readonly statusCode: 403 | 413 | 415) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecognizedVideo = (header: Buffer) => {
|
||||||
|
// ISO Base Media (MP4/MOV): bytes 4–7 identify the file type box.
|
||||||
|
const isMp4Family = header.length >= 12 && header.subarray(4, 8).toString('ascii') === 'ftyp';
|
||||||
|
// WebM/Matroska EBML signature.
|
||||||
|
const isWebm = header.length >= 4 && header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
|
||||||
|
return isMp4Family || isWebm;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ValidatedVideoStream extends Transform {
|
||||||
|
private totalBytes = 0;
|
||||||
|
private header = Buffer.alloc(0);
|
||||||
|
private validated = false;
|
||||||
|
|
||||||
|
constructor(private readonly maxBytes: number) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null, data?: Buffer) => void) {
|
||||||
|
this.totalBytes += chunk.length;
|
||||||
|
if (this.totalBytes > this.maxBytes) {
|
||||||
|
callback(new VideoUploadValidationError('Video is larger than the configured upload limit.', 413));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.validated) {
|
||||||
|
this.header = Buffer.concat([this.header, chunk]).subarray(0, 32);
|
||||||
|
if (this.header.length >= 12) {
|
||||||
|
if (!isRecognizedVideo(this.header)) {
|
||||||
|
callback(new VideoUploadValidationError('Only valid MP4, WebM, and MOV video files are accepted.', 415));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.validated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(null, chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
override _flush(callback: (error?: Error | null) => void) {
|
||||||
|
if (!this.validated) {
|
||||||
|
callback(new VideoUploadValidationError('The uploaded file is not a valid video.', 415));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateVideoUpload(input: Readable, contentType: string | undefined, maxBytes: number) {
|
||||||
|
const normalizedType = contentType?.split(';')[0]?.toLowerCase();
|
||||||
|
if (!normalizedType || !allowedContentTypes.has(normalizedType)) {
|
||||||
|
throw new VideoUploadValidationError('Only MP4, WebM, and MOV video uploads are accepted.', 415);
|
||||||
|
}
|
||||||
|
return input.pipe(new ValidatedVideoStream(maxBytes));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user