πŸ“Έ

Content Sharing

Upload-flow, CDN-delivery, Freeze & Reveal premium content, video transcoding pipeline en consent logging.

Fastify 5 PostgreSQL 16 + Drizzle ORM React 19 + TanStack Query v5 S3 / Cloudinary FFmpeg HLS

πŸ“± 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(),
});