π± Content Types
Vijf content-formaten, elk met eigen upload-limiet, processing-pipeline en monetisatielogica.
πΈ
Photos
- β’ Max 10 per post
- β’ Max 10 MB/afbeelding
- β’ Auto WebP-convert
- β’ EXIF stripping
π¬
Videos
- β’ Max 500 MB
- β’ Max 60 min
- β’ FFmpeg β HLS
- β’ Auto-thumbnail
β‘
Stories
- β’ 24 uur TTL
- β’ Foto of video
- β’ Swipeable carousel
- β’ View counter
ποΈ
Reels
- β’ Short-form video
- β’ Max 90 sec
- β’ Vertical 9:16
- β’ Algo-boost eligible
π€
AI-Generated
- β’ AI-label verplicht
- β’ Consent check
- β’ Teezer watermark
- β’ ai_generated=true
β¬οΈ Upload Flow β Multipart β S3 β Database
1 Β· Multipart upload
β
2 Β· Validatie & scan
β
3 Β· S3 / Cloudinary
β
4 Β· Thumbnail gen
β
5 Β· DB record
β
6 Β· Feed indexing
Fastify 5 route
// POST /api/content (multipart)
fastify.post('/content', {
preHandler: [authenticate, checkRole(['creator'])],
schema: { consumes: ['multipart/form-data'] },
}, async (req, reply) => {
const data = await req.file(); // busboy
const s3Key = await uploadToS3(data); // stream to S3
const thumb = await generateThumb(s3Key);
const record = await db.insert(content).values({
creatorId: req.user.id,
s3Key, thumbnailKey: thumb,
type: data.fields.type.value,
isPremium: data.fields.isPremium.value === 'true',
tokenPrice: Number(data.fields.price.value) || 0,
}).returning();
return reply.status(201).send(record[0]);
});
Video Transcoding (FFmpeg β HLS)
// services/transcoder.ts
import ffmpeg from 'fluent-ffmpeg';
export async function transcodeToHLS(s3Key: string) {
const input = await downloadFromS3(s3Key);
return new Promise((resolve, reject) => {
ffmpeg(input)
.outputOptions([
'-codec: copy',
'-start_number 0',
'-hls_time 4',
'-hls_list_size 0',
'-f hls',
])
.output(`/tmp/${s3Key}/index.m3u8`)
.on('end', async () => {
const hlsKey = await uploadHLSToS3(s3Key);
resolve(hlsKey);
})
.on('error', reject)
.run();
});
}
π§ Freeze & Reveal β Premium Content System
Premium content wordt geblurd weergegeven. Unlock kost tokens. Elke unlock wordt gelogd in consent_logs.
Visueel voorbeeld
π₯
π§
Premium Content
Ontgrendel voor 50 tokens
Blur-laag via CSS backdrop-filter: blur() + overlay. Signed URL alleen na unlock.
Unlock + Consent Logging
// POST /api/content/:id/unlock
async function unlockContent(req, reply) {
const { id } = req.params;
const user = req.user;
// 1. Check token balance
const item = await db.query.content
.findFirst({ where: eq(content.id, id) });
if (user.tokenBalance < item.tokenPrice)
return reply.status(402).send({ error: 'Insufficient tokens' });
// 2. Deduct tokens
await db.update(users).set({
tokenBalance: sql`token_balance - ${item.tokenPrice}`
}).where(eq(users.id, user.id));
// 3. Log unlock in consent_logs
await db.insert(consentLogs).values({
userId: user.id,
contentId: id,
creatorId: item.creatorId,
tokensPaid: item.tokenPrice,
action: 'freeze_reveal_unlock',
});
// 4. Return signed S3 URL (1h expiry)
const url = await getPresignedUrl(item.s3Key, 3600);
return reply.send({ url });
}
β οΈ Consent Logging: Elke Freeze & Reveal unlock schrijft een record naar
consent_logs met userId, contentId, creatorId, tokensPaid en timestamp. Vereist voor GDPR-compliance en creator payouts.
π CDN β Signed URLs voor PrivΓ© Content
S3 Presigned URLs (1h expiry)
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'eu-west-1' });
export async function getPresignedUrl(key: string, ttl = 3600) {
const cmd = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
});
return getSignedUrl(s3, cmd, { expiresIn: ttl });
}
// Publieke content β CloudFront CDN (geen signing)
// PrivΓ© / premium β presigned URL, 3600s TTL
Regels
- βPublieke content β CloudFront CDN, geen signing, lang gecached (7 dagen)
- πPremium / privΓ© β S3 presigned URL, 1h TTL, gegenereerd on-demand na unlock
- ποΈHLS video streams β Signed CloudFront URL per segment, token in query string
- πΌοΈThumbnails β altijd publiek, gegenereerd via FFmpeg (video) of Sharp (foto)
- β οΈAI-generated content β zelfde flow, extra metadata-veld
ai_generated: true
π οΈ API Endpoints
POST
/api/content
Multipart upload β foto, video, story, reel, AI
Content-Type: multipart/form-data Β· Auth: Bearer JWT Β· Role: creator
GET
/api/feed
Gepagineerde feed voor ingelogde user
?page=1&limit=20&type=video&premium=false Β· Response: { data:[], total, nextCursor }
GET
/api/content/:id
Content detail. Signed URL als user unlock heeft. Thumbnail altijd zichtbaar.
POST
/api/content/:id/unlock
Freeze & Reveal unlock. Deduceert tokens, logt in consent_logs, retourneert signed URL.
DELETE
/api/content/:id
Soft delete. S3 object blijft 30 dagen (GDPR-window). Alleen eigen content of admin.
ποΈ Database Schema β PostgreSQL 16 + Drizzle ORM
// schema/content.ts
export const content = pgTable('content', {
id: uuid('id').defaultRandom().primaryKey(),
creatorId: uuid('creator_id').notNull().references(() => users.id),
type: varchar('type', { length: 32 }).notNull(),
// 'photo' | 'video' | 'story' | 'reel' | 'ai_generated'
s3Key: text('s3_key').notNull(),
thumbnailKey: text('thumbnail_key'),
hlsKey: text('hls_key'), // video/reel only
isPremium: boolean('is_premium').default(false),
tokenPrice: integer('token_price').default(0),
aiGenerated: boolean('ai_generated').default(false),
expiresAt: timestamp('expires_at'), // stories: +24h
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').defaultNow(),
});
export const consentLogs = pgTable('consent_logs', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
contentId: uuid('content_id').notNull().references(() => content.id),
creatorId: uuid('creator_id').notNull(),
tokensPaid: integer('tokens_paid').notNull(),
action: varchar('action', { length: 64 }).notNull(),
// 'freeze_reveal_unlock' | 'subscription_gate' | 'ppv'
createdAt: timestamp('created_at').defaultNow(),
});