Internal Documentation internal
TalkIDE internal documentation

User appka požádá storage-svc o presigned PUT URL pro nový objekt v R2 bucketu. Storage-svc validuje request (HMAC token, content-type, content-length, key sanity, quota) a vrátí presigned URL s 15min TTL. Samotný upload pak user-app / browser udělá přímo na Cloudflare R2 — storage-svc se přenosu dat neúčastní.

  • Endpoint hostován v storage-svc (per-namespace mikroservis, viz UC-12001).
  • Volání NENÍ přímo z browseru, ale z user-app BE (server-side; HMAC token nesmí být v browseru).
  • User-app si schéma volání vytváří přes talkide-storage-sdk (viz UC-12005).
  • TTL 15 min — dost pro browser upload velkých souborů (až 100 MB MVP) i s retry.

sequenceDiagram
    actor EndUser as User Browser
    participant UA as User App BE
    participant SS as storage-svc
    participant CF as Cloudflare R2

    EndUser->>+UA: action that needs file upload

    UA->>+SS: POST /api/v1/storage/presign-upload <br> X-Talkide-App-Token: {hmac} <br> { key, contentType, contentLength }

    SS->>SS: verify HMAC token (constant-time compare)
    alt token invalid
        SS-->>UA: 401 Unauthorized <br> ErrorResponse
    end

    SS->>SS: validate request (key sanity, content-type, content-length ≤ 100 MB)
    alt invalid
        SS-->>UA: 400 Bad Request <br> ErrorResponse
    end

    SS->>SS: quota check (skip if quota_bytes IS NULL — unlimited default)
    alt quota_bytes IS NOT NULL AND bytes_used + contentLength > quota_bytes
        SS-->>UA: 429 Too Many Requests <br> ErrorResponse
    end

    SS->>SS: build canonical key (no traversal, no leading slash)

    SS->>+CF: AWS SigV4 sign — presigned PUT URL <br> Expires=15min, ContentType, ContentLength
    CF->>-SS: signed URL

    SS->>-UA: 200 OK <br> { uploadUrl, key, expiresAt }

    UA->>-EndUser: relay uploadUrl (e.g. via own endpoint, or include in form response)

    EndUser->>+CF: PUT {uploadUrl} <br> Content-Type, Content-Length, body bytes
    CF->>-EndUser: 200 OK

API Contract

POST /api/v1/storage/presign-upload PresignUploadRequest:

{
  "key": "uploads/avatars/user-123.png",
  "contentType": "image/png",
  "contentLength": 524288
}

Headers:

  • X-Talkide-App-Token: <32B hex HMAC token> — required. Hodnota = TALKIDE_APP_STORAGE_TOKEN z user-app env.
  • Content-Type: application/json

200 OK PresignUploadResponse:

{
  "data": {
    "uploadUrl": "https://{accountId}.r2.cloudflarestorage.com/talkide-app-popelkam-todo-list/uploads/avatars/user-123.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Date=...&X-Amz-Expires=900&X-Amz-Signature=...",
    "key": "uploads/avatars/user-123.png",
    "expiresAt": "2026-05-24T10:30:00Z"
  }
}

User-app pak vrací uploadUrl browseru / klientovi, který udělá:

PUT {uploadUrl}
Content-Type: image/png
Content-Length: 524288

<binary bytes>

R2 vrátí 200 OK při úspěchu, 403 Forbidden (expired/invalid signature) nebo 400 Bad Request (content-length mismatch).


Error responses

400 Bad Request (invalid key — traversal / forbidden chars) ErrorResponse:

{
  "status": 400,
  "code": "STORAGE_INVALID_KEY",
  "message": "Key contains forbidden characters or path traversal"
}

400 Bad Request (content-length over limit) ErrorResponse:

{
  "status": 400,
  "code": "STORAGE_OBJECT_TOO_LARGE",
  "message": "Object exceeds the 100 MB limit"
}

400 Bad Request (content-type missing or blank) ErrorResponse:

{
  "status": 400,
  "code": "STORAGE_CONTENT_TYPE_REQUIRED",
  "message": "contentType is required"
}

400 Bad Request (validation) ErrorResponse:

{
  "status": 400,
  "code": "VALIDATION_ERROR",
  "message": "Validation failed",
  "errors": [
    { "field": "key", "message": "must not be blank" }
  ]
}

401 Unauthorized (missing or invalid HMAC token) ErrorResponse:

{
  "status": 401,
  "code": "AUTHENTICATION_FAILED",
  "message": "Invalid or missing storage token"
}

429 Too Many Requests (quota exceeded) ErrorResponse:

{
  "status": 429,
  "code": "STORAGE_QUOTA_EXCEEDED",
  "message": "Project storage quota exceeded"
}

500 Internal Server Error (Cloudflare sign-URL failure — síťová chyba) ErrorResponse:

{
  "status": 500,
  "code": "STORAGE_PRESIGN_FAILED",
  "message": "Failed to generate presigned URL"
}

Key sanity rules

Storage-svc canonicalizes a validates key před voláním S3 SDK:

  1. Trim: odřízne leading/trailing whitespace.
  2. No leading slash: /foo → odmítnuto (400 STORAGE_INVALID_KEY).
  3. No .. segments: foo/../bar → odmítnuto.
  4. No double slashes: foo//bar → odmítnuto.
  5. No control chars / NUL bytes: odmítnuto.
  6. Length: 1 ≤ len(key) ≤ 1024 znaků (R2 limit).
  7. Encoding: UTF-8; non-printable znaky odmítnuto.
private val KEY_REGEX = Regex("^[\\p{L}\\p{N}\\p{P}\\p{S} ]{1,1024}$")
private val FORBIDDEN_SEGMENTS = setOf("..", ".", "")

fun sanitizeKey(raw: String): String {
    val trimmed = raw.trim()
    require(trimmed.isNotBlank() && trimmed.length <= 1024) { "STORAGE_INVALID_KEY" }
    require(!trimmed.startsWith("/")) { "STORAGE_INVALID_KEY" }
    require(KEY_REGEX.matches(trimmed)) { "STORAGE_INVALID_KEY" }
    val segments = trimmed.split("/")
    require(segments.none { it in FORBIDDEN_SEGMENTS }) { "STORAGE_INVALID_KEY" }
    return trimmed
}

Quota check

TL;DR (OD-1 z README): default quota_bytes = NULLquota check je full-skip. Primary billing mechanism = snapshot ledger (UC-12006), ne enforcement. Hard limit zůstává jen jako volitelná bezpečnostní pojistka.

Cesta A — quota_bytes IS NULL (default, unlimited)

Storage-svc quota filtr úplně přeskočí (early-return v QuotaCheckFilter.shouldSkip()). Žádný roundtrip do platform BE, žádný in-memory cache lookup. Tohle je nejčastější cesta a má nulový overhead.

Cesta B — quota_bytes IS NOT NULL (admin override = bezpečnostní pojistka)

Storage-svc nedrží authoritative bytes_used — to drží platform talkide-be v app_storage_config (best-effort live čítač) a v storage_usage_snapshot (authoritative, viz UC-12006). Storage-svc dělá periodic refresh (každých 60s, Spring @Scheduled) přes interní platform endpoint:

GET https://api.talkide.app/internal/storage/quota?bucketName=talkide-app-popelkam-todo-list
Authorization: Bearer <PLATFORM_INTERNAL_TOKEN of caller — storage-svc has its own, platform has its own>
→ 200 OK
{ "quotaBytes": 10737418240, "bytesUsed": 523456789 }

Cache v paměti storage-svc (1 min TTL). Presign-upload odmítne s 429 STORAGE_QUOTA_EXCEEDED, pokud bytesUsed + contentLength > quotaBytes (s 5 % bezpečnostní marží — eventually consistent, race window OK; přesnou hodnotu dorovná snapshot scheduler v UC-12006).

Pokud platform internal endpoint je unreachable (síťový výpadek, platform deploy in-progress): storage-svc spadne do fail-open módu (log WARN, presign vrácen). Důvod: spíš přijmeme drobné překročení limitu než zablokujeme všechny uploady tenanta při kterémkoli incidentu na control-plane. Snapshot ledger UC-12006 alertuje admin, který může intervence ručně.

Authoritative usage

storage_usage_snapshot ledger v UC-12006 (30min cron iteruje aktivní projekty, čte bytes_used ze storage-svc /internal/usage, append-only zápis). Slouží primárně jako vstup pro billing line item (následný UC-10019 v UC-10 Stripe Billing), sekundárně jako single source of truth pro reconciliation s live čítačem v app_storage_config.bytes_used.


Frontend

User-app může mít vlastní FE (Vue/React) — to není v scope tohoto UC. Storage SDK volání jde z user-app BE.

Validations (v user-app SDK před voláním storage-svc)

FieldConstraintsSizePatternNote
keynot_blank, no traversal1 – 1024 charsviz sanitizeKeySDK odmítne lokálně před voláním storage-svc (rychlejší error)
contentTypenot_blankMIME-likeSDK nevaliduje konkrétní MIME (OD-4 — user app rozhoduje)
contentLengthnot_null, positive1 – 104 857 600 B100 MB hard limit, MVP single PUT

Backend (storage-svc)

Validations

FieldConstraintsSizePatternNote
X-Talkide-App-Token (header)not_blank, constant-time compare s SHA-256 hash z app_storage_token_hash64 hex chars^[0-9a-f]{64}$HMAC, 32B raw → 64 hex
keynot_blank, sanitized1 – 1024 charsviz sanitizeKeyPo sanitizaci passed do S3 SDK
contentTypenot_blank1 – 255 charsŽádný MIME allow-list (OD-4)
contentLengthnot_null, positive1 – 104 857 600 B100 MB

Test Cases

GIVENWHENTHEN
valid HMAC token, valid key/contentType/contentLengthPOST /presign-upload je volán200 OK; vrácen uploadUrl s R2 SigV4 podpisem, expiresAt ~15 min v budoucnu
Authorization header chybíPOST /presign-upload je volán401 AUTHENTICATION_FAILED
HMAC token nesprávnýPOST /presign-upload je volán401 AUTHENTICATION_FAILED (constant-time compare aby nebyl timing attack)
key obsahuje ..POST /presign-upload je volán400 STORAGE_INVALID_KEY
key začíná /POST /presign-upload je volán400 STORAGE_INVALID_KEY
key obsahuje //POST /presign-upload je volán400 STORAGE_INVALID_KEY
key obsahuje NUL bytePOST /presign-upload je volán400 STORAGE_INVALID_KEY
key je 1025 znaků dlouhýPOST /presign-upload je volán400 STORAGE_INVALID_KEY
contentLength = 104 857 600 (100 MB)POST /presign-upload je volán200 OK (boundary je inclusive)
contentLength = 104 857 601 (100 MB + 1B)POST /presign-upload je volán400 STORAGE_OBJECT_TOO_LARGE
contentType chybíPOST /presign-upload je volán400 STORAGE_CONTENT_TYPE_REQUIRED
quota_bytes IS NULL (default, unlimited), contentLength = 100 MB, bytes_used libovolnéPOST /presign-upload je volán200 OK; quota check je skipped (full-skip path), žádný roundtrip do platform BE
quota_bytes = 10 GB, bytes_used = 0, contentLength = 100 MBPOST /presign-upload je volán200 OK
quota_bytes = 1 GB, bytes_used = 950 MB, contentLength = 100 MBPOST /presign-upload je volán429 STORAGE_QUOTA_EXCEEDED
quota_bytes = 1 GB, bytes_used = 0, contentLength = 1 073 741 825 B (přesahuje quotu samo o sobě)POST /presign-upload je volán429 STORAGE_QUOTA_EXCEEDED
quota_bytes IS NOT NULL, platform internal quota endpoint je unreachablestorage-svc startuje, žádná cachefail-open: quota check skipped (log WARN); presign vrácen — snapshot scheduler v UC-12006 alertuje admin
AWS SDK presign volání selže (síťová chyba)POST /presign-upload je volán500 STORAGE_PRESIGN_FAILED; log ERROR
validní upload URL, klient PUT na R2klient uploaduje za 14 minut od presign200 OK od R2 (URL stále platná)
validní upload URL, klient PUT na R2klient uploaduje za 16 minut od presign403 Forbidden od R2 (URL expired)
presign-upload pak ihned klient PUT s ContentType odlišným od presignuklient uploaduje403 Forbidden od R2 (signature mismatch — R2 SigV4 váže ContentType)

HMAC token verification (storage-svc internals)

@Component
class AppStorageTokenAuthFilter(
    @Value("\${app.storage.token}") private val expectedToken: String,  // z env APP_STORAGE_TOKEN
) : OncePerRequestFilter() {

    override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) {
        val header = req.getHeader("X-Talkide-App-Token")
        if (header == null || !constantTimeEquals(header, expectedToken)) {
            res.status = 401
            res.contentType = "application/json"
            res.writer.write("""{"status":401,"code":"AUTHENTICATION_FAILED","message":"Invalid or missing storage token"}""")
            return
        }
        chain.doFilter(req, res)
    }

    private fun constantTimeEquals(a: String, b: String): Boolean = MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
}

Pozn.: storage-svc nepoužívá JWT/Spring Security plně — jen tento jednoduchý filter. Důvod: in-namespace pod, K8s NetworkPolicy už zabraňuje externímu přístupu; HMAC je obrana v hloubce.


Out of scope (this UC)

  • Multipart upload (init / parts / complete) — UC-future.
  • Public ACL na objektu (x-amz-acl: public-read) — OD-2, MVP signed-only.
  • Custom metadata na presign (x-amz-meta-*) — UC-future.
  • Chunked / resumable upload — UC-future.

Was this page helpful?

Thanks for the feedback.