Image uploads and moderation

How to let people post photos in chat. The engine does not accept files. A message carries only a URL and metadata; receiving, storing and checking the file is the job of your backend and your bucket. This page is the recommended structure on AWS (S3 + Rekognition).

One idea holds it together: a file is in a private prefix until it passes the check, and only files that pass are moved to a public prefix. Then all the before_publish hook has to do is confirm, with a string comparison, that a URL is this sender's file under your public prefix.

What the engine does and does not do

Does Stores body.attachments[] and delivers it to the room. Up to 10 per message by default, and url must start with https://
Does not Receive files. It does not open the URL, convert the format, inspect the file or delete it
Attachment shape { type: "image", url, key?, mime?, w?, h?, name?, size? } — the engine reads only url; the rest is for your UI
Export GET /v1/export contains this metadata only, no files. You take the files from your own bucket

The flow

1 browser ──→ your backend     ask to upload → presigned POST (type, size and expiry bound into the signature)
2 browser ──→ S3 incoming/     POST the file straight to S3 (private prefix, never through your server)
3 browser ──→ your backend     "I'm done uploading"
4 your backend ←→ S3, Rekognition   read → re-encode → DetectModerationLabels
                               pass: write to public/ and delete incoming/ / fail: delete incoming/, 422
5 browser ──→ chat engine      room.send({ attachments: [the attachment step 4 returned] })
6 chat engine ──→ your backend before_publish: is the URL under your CDN's public/{sender}/ ?

Why each choice:

Choice Reason
Two prefixes incoming/ is not behind the CDN, so nobody can see a file before it is checked. Only your backend writes public/
Bind type and size into the signature If the browser breaks the policy, S3 refuses the upload itself. A presigned PUT cannot bind the size, so use a POST policy
User id in the key The hook can check "only your own files" as a string, and on account deletion you can remove public/{userId}/ in one sweep
The server reads the bytes from S3 itself Having the server fetch a URL the browser supplied is an SSRF path to internal addresses
Check after re-encoding EXIF (shooting location and so on) is gone, files disguised as images are filtered out, and the bytes you checked are the bytes you serve
The hook never calls AWS The hook is a synchronous call that blocks the publish (1.5 seconds by default, 5 at most). Slow checks finish on the upload side

1. Bucket and permissions

Bucket my-chat-uploads blocks all public access. The browser POSTs straight to S3, so it needs CORS.

json
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["POST"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]

The minimum permissions for your backend's role. Don't put keys in code; give them through the IAM role of the environment you run in (EC2, ECS, Lambda).

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Incoming",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-chat-uploads/incoming/*"
    },
    {
      "Sid": "Public",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:DeleteObject"],
      "Resource": "arn:aws:s3:::my-chat-uploads/public/*"
    },
    {
      "Sid": "ListPublicForCleanup",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::my-chat-uploads",
      "Condition": { "StringLike": { "s3:prefix": ["public/*"] } }
    },
    {
      "Sid": "Moderation",
      "Effect": "Allow",
      "Action": "rekognition:DetectModerationLabels",
      "Resource": "*"
    }
  ]
}

Files that fail the check, or that a user never finishes (step 3), stay only in incoming/, so sweep that prefix with a lifecycle rule.

json
{
  "Rules": [
    {
      "ID": "expire-incoming",
      "Status": "Enabled",
      "Filter": { "Prefix": "incoming/" },
      "Expiration": { "Days": 1 }
    }
  ]
}

2. Backend: issuing the upload ticket

Issue it only to signed-in users. Put the exact key, content type, size range and expiry in the policy, and let the server choose the key.

sh
npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-rekognition sharp
ts
// uploads.ts
import { randomUUID } from 'node:crypto'
import { S3Client } from '@aws-sdk/client-s3'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'

export const s3 = new S3Client({}) // region and credentials come from the environment
export const BUCKET = process.env.UPLOAD_BUCKET! // my-chat-uploads
export const MAX_BYTES = 5_000_000
const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) // no SVG (it can carry script)

// userId is your service's user id. It goes into the key, so it must be limited to [A-Za-z0-9_-].
export async function issueUpload(userId: string, contentType: string) {
  if (!TYPES.has(contentType)) throw new HttpError(400, 'invalid_content_type')

  const id = randomUUID()
  const { url, fields } = await createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: `incoming/${userId}/${id}`,
    Fields: { 'Content-Type': contentType },
    Conditions: [
      ['eq', '$Content-Type', contentType], // this one type only
      ['content-length-range', 1, MAX_BYTES] // at least 1 byte, at most MAX_BYTES
    ],
    Expires: 60 // seconds. Keep it short
  })
  return { id, url, fields }
}
ts
// Route example (Express). Authentication is your service's session
app.post('/api/uploads', requireLogin, async (req, res) => {
  res.json(await issueUpload(req.user.id, String(req.body.contentType)))
})

Replace HttpError with your service's error type. Rate-limit tickets per user here too (see "Things to keep in mind" below).

3. Browser: upload, notify, send

In an S3 form the file field must come last. Fill the other fields first and append the file at the end.

ts
import { ChatError } from '@kispi/chat'
import { chat } from './chat' // the createChatClient from Getting started

const room = chat.roomByKey('lobby')
await room.subscribe()

async function sendImage(file: File, text?: string) {
  // 1. Ticket
  const ticket = await api('/api/uploads', { contentType: file.type })

  // 2. Upload straight to S3
  const form = new FormData()
  for (const [name, value] of Object.entries(ticket.fields)) form.append(name, value as string)
  form.append('file', file) // last
  const up = await fetch(ticket.url, { method: 'POST', body: form })
  if (!up.ok) throw new Error('upload_failed')

  // 3. Check and make public (backend). Returns the attachment object if it passes
  const attachment = await api(`/api/uploads/${ticket.id}/complete`)

  // 4. Publish to the chat engine. Send the server's object as it is
  try {
    await room.send({ text, attachments: [attachment] })
  } catch (err) {
    if (err instanceof ChatError && err.code === 'moderation_denied') {
      showError(err.message) // the hook refused. You can branch on err.appCode
    } else throw err
  }
}

// A small helper: POST JSON, read JSON. Throws on non-2xx
async function api(path: string, body?: unknown) {
  const res = await fetch(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body ?? {})
  })
  if (!res.ok) throw new Error((await res.json()).code ?? 'request_failed')
  return res.json()
}

4. Backend: check, then move to public

The endpoint step 3 calls. It does read → re-encode → check → store publicly → delete the original, in that order.

ts
// uploads.ts (continued)
import { GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
import { RekognitionClient, DetectModerationLabelsCommand } from '@aws-sdk/client-rekognition'
import sharp from 'sharp'

const rekognition = new RekognitionClient({})
export const CDN = process.env.CDN_ORIGIN! // https://cdn.example.com
export const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'

const MIN_CONFIDENCE = 80 // Rekognition returns no label below this confidence
// Choose by your service's policy. Take names from the moderation label list (L1, L2) in the AWS docs
const BLOCKED = new Set([
  'Explicit',
  'Graphic Violence',
  'Visually Disturbing',
  'Hate Symbols'
])

const drop = (Key: string) =>
  s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key }))

export async function completeUpload(userId: string, id: string) {
  if (!new RegExp(`^${UUID}$`).test(id)) throw new HttpError(400, 'invalid_id')
  const inKey = `incoming/${userId}/${id}` // someone else's key cannot be built

  // 1) Read. The policy capped the size, but the server looks once more
  const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: inKey }))
  if ((obj.ContentLength ?? 0) > MAX_BYTES) {
    await drop(inKey)
    throw new HttpError(413, 'too_large')
  }
  const raw = Buffer.from(await obj.Body!.transformToByteArray())

  // 2) Re-encode. If it will not decode it is not an image. sharp drops EXIF and GPS from the output
  let out: { data: Buffer; info: sharp.OutputInfo }
  try {
    out = await sharp(raw, { limitInputPixels: 40_000_000 })
      .rotate() // apply the EXIF orientation to the pixels first (the metadata is dropped)
      .resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true })
      .flatten({ background: '#ffffff' }) // background when a transparent PNG becomes a JPEG
      .jpeg({ quality: 85 })
      .toBuffer({ resolveWithObject: true })
  } catch {
    await drop(inKey)
    throw new HttpError(422, 'invalid_image')
  }

  // 3) Check. Send the bytes you will serve (the re-encoded result)
  let labels
  try {
    const res = await rekognition.send(
      new DetectModerationLabelsCommand({
        Image: { Bytes: out.data },
        MinConfidence: MIN_CONFIDENCE
      })
    )
    labels = res.ModerationLabels ?? []
  } catch {
    // If the check did not run, do not let it through. incoming/ stays, so the same request can be retried
    throw new HttpError(502, 'check_failed')
  }
  const hit = labels.find(
    (l) => BLOCKED.has(l.Name ?? '') || BLOCKED.has(l.ParentName ?? '')
  )
  if (hit) {
    await drop(inKey)
    console.warn('image rejected', { userId, label: hit.Name, confidence: hit.Confidence })
    throw new HttpError(422, 'image_rejected')
  }

  // 4) Store publicly. Only this function writes this prefix
  const key = `public/${userId}/${id}.jpg`
  await s3.send(
    new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      Body: out.data,
      ContentType: 'image/jpeg',
      CacheControl: 'public, max-age=31536000, immutable' // the key is unique, so caching forever is fine
    })
  )
  await drop(inKey)

  return {
    type: 'image',
    url: `${CDN}/${key}`,
    key,
    mime: 'image/jpeg',
    w: out.info.width,
    h: out.info.height,
    size: out.info.size
  }
}
ts
app.post('/api/uploads/:id/complete', requireLogin, async (req, res) => {
  res.json(await completeUpload(req.user.id, req.params.id))
})
  • Rekognition accepts JPEG and PNG only. Converting to JPEG first, as above, lets you accept WebP too.
  • Labels are hierarchical. When a child label (L2) matches, its parent (L1) comes back with it, so look for the name in both Name and ParentName. AWS recommends filtering on L1 and L2. A lower MIN_CONFIDENCE catches more (more false positives) and a higher one catches less (more misses) — choose for your service; the default is 50.
  • Show users a generic message for a rejection, not the label name.

5. The hook: confirm the attachment is your public file

The engine checks only that the URL is https://. So without a hook, a client can send any URL as an attachment (an unchecked outside image, a tracking pixel). Turn on before_publish in the console's webhook screen, and if you already receive webhooks at an endpoint, branch on event in the same route.

ts
import express from 'express'
import { createChatServer } from '@kispi/chat/server'
import { CDN, UUID } from './uploads'

const chat = createChatServer({
  secretKey: process.env.CHAT_SECRET_KEY!,
  webhookSecret: process.env.CHAT_WEBHOOK_SECRET
})

type BeforePublish = {
  op: 'create' | 'update'
  sender: { id: string; name: string }
  body: { text?: string; attachments?: { type?: string; url?: unknown }[] }
}

// Must be under the sender's own public prefix, in the file-name shape we generate
const fileName = new RegExp(`^${UUID}\\.jpg$`)
const isMine = (url: unknown, senderId: string) => {
  const prefix = `${CDN}/public/${senderId}/`
  return typeof url === 'string' && url.startsWith(prefix) && fileName.test(url.slice(prefix.length))
}

function decide({ sender, body }: BeforePublish) {
  for (const a of body.attachments ?? []) {
    if (a.type !== 'image' || !isMine(a.url, sender.id)) {
      return { action: 'deny', code: 'attachment_not_allowed', message: 'That attachment is not allowed' }
    }
  }
  return { action: 'allow' }
}

app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
  let hook
  try {
    hook = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
  } catch {
    return res.sendStatus(400) // the signature is wrong
  }
  if (hook.event === 'before_publish') return res.json(decide(hook.data as BeforePublish))
  // Handle the other events (message.created and so on) as in the receiving code on the webhooks page
  res.sendStatus(200)
})
  • The hook covers both publishes and edits, so editing in someone else's URL is stopped by the same code.
  • The code you return reaches the client as err.appCode, and message as the error text.
  • If your UI also turns image URLs inside the message text into pictures, the hook has to check the URLs in body.text as well. It is easier to render attachments only.
  • Messages sent with a server key (sk_) skip the hook. When your own backend posts an image, use the step 4 result as it is.

Keep the bucket private and let CloudFront read it. Make the bucket the distribution's origin with origin access control (OAC) on, and open only one path pattern, public/*, in the behavior. incoming/ is unreachable through the CDN.

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudFrontReadPublicOnly",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-chat-uploads/public/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EXXXXXXXXXXXXX"
        }
      }
    }
  ]
}

Add X-Content-Type-Options: nosniff in a response headers policy, and attach your own domain (cdn.example.com); that becomes CDN_ORIGIN in step 4. This address is the front part of the attachment url, so it must be the same value as CDN in the hook.

Text moderation

Filter text in the same hook. The engine does not escape the body — filtering words is the hook's job, and escaping when you render is the renderer's job.

ts
const BAD = /badword1|badword2/gi // your service's word list

// call this from decide() after the attachment checks pass
async function decideText(body: BeforePublish['body']) {
  const text = body.text ?? ''
  const masked = text.replace(BAD, (m) => '*'.repeat(m.length))
  if (masked !== text) return { action: 'allow', body: { ...body, text: masked } } // allow, with the change
  return { action: 'allow' }
}
  • The body you return is the whole body. Send only part of it and the rest disappears.
  • If you call an external moderation service, put a timeout shorter than the hook's time limit on it yourself (the hook defaults to 1.5 seconds and tops out at 5). Cut it off with something like AbortSignal.timeout(1000) and decide in code whether to allow or refuse when that happens.
  • When the hook itself times out or errors, the engine follows before_publish_on_error, which defaults to allow. If you rely on the hook for attachment checks, that means "the checks are off while the hook is down." Switching to deny is safe, but every publish in that app stops while the hook is down — keep the hook light and monitor it.

Things to keep in mind

  • Size. Limit it in three layers: the signed condition (content-length-range), the server's recheck and a pixel cap (limitInputPixels). Rekognition also has an image size limit, so check AWS's limits page (Guidelines and quotas) and keep MAX_BYTES inside it.
  • Formats. Accept JPEG, PNG and WebP only. SVG can carry script, so refuse it. Animated GIFs are easy to get around if you check only the first frame, so it is safer not to accept them.
  • EXIF. Re-encoding removes it. If you must serve the original, add a step that strips the GPS data from EXIF.
  • Rate limits. Limit ticket issuing and complete per user. Every complete call costs a Rekognition request.
  • Cost. Rekognition bills per image. Prices change, so open the Amazon Rekognition pricing page on AWS, find Image Moderation, and multiply by your expected uploads. S3 storage and CloudFront transfer cost extra.
  • Limits of the check. AWS states that Rekognition does not detect whether an image is illegal content such as CSAM. Put a report button, human review and the legal duties your service carries (reporting and takedown procedures) in place yourself.
  • Deletion. Deleting a message (message.deleted) does not delete the file in your bucket. To delete the file too, record body.attachments[].key from the message.created webhook together with the message id in your DB, and when message.deleted ({id, seq}) arrives, delete that key's object. For removals after a report, tie chat.messages.delete, the object deletion and, if needed, a ban into one flow. For account deletion and privacy deletion requests, list everything under public/{userId}/ and delete it.
  • Export. The whole-app export (GET /v1/export) does not include attachment files. Back up and migrate the files on the bucket side (replication, aws s3 sync).
  • Keys. Keep sk_, the webhook secret and AWS credentials out of the browser and out of the repository. Take AWS access from the IAM role of the environment you run in.