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_TOKENz 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:
- Trim: odřízne leading/trailing whitespace.
- No leading slash:
/foo→ odmítnuto (400 STORAGE_INVALID_KEY). - No
..segments:foo/../bar→ odmítnuto. - No double slashes:
foo//bar→ odmítnuto. - No control chars / NUL bytes: odmítnuto.
- Length: 1 ≤ len(key) ≤ 1024 znaků (R2 limit).
- 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 = NULL→ quota 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)
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| key | not_blank, no traversal | 1 – 1024 chars | viz sanitizeKey | SDK odmítne lokálně před voláním storage-svc (rychlejší error) |
| contentType | not_blank | — | MIME-like | SDK nevaliduje konkrétní MIME (OD-4 — user app rozhoduje) |
| contentLength | not_null, positive | 1 – 104 857 600 B | — | 100 MB hard limit, MVP single PUT |
Backend (storage-svc)
Validations
| Field | Constraints | Size | Pattern | Note |
|---|---|---|---|---|
| X-Talkide-App-Token (header) | not_blank, constant-time compare s SHA-256 hash z app_storage_token_hash | 64 hex chars | ^[0-9a-f]{64}$ | HMAC, 32B raw → 64 hex |
| key | not_blank, sanitized | 1 – 1024 chars | viz sanitizeKey | Po sanitizaci passed do S3 SDK |
| contentType | not_blank | 1 – 255 chars | — | Žádný MIME allow-list (OD-4) |
| contentLength | not_null, positive | 1 – 104 857 600 B | — | 100 MB |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| valid HMAC token, valid key/contentType/contentLength | POST /presign-upload je volán | 200 OK; vrácen uploadUrl s R2 SigV4 podpisem, expiresAt ~15 min v budoucnu |
| Authorization header chybí | POST /presign-upload je volán | 401 AUTHENTICATION_FAILED |
| HMAC token nesprávný | POST /presign-upload je volán | 401 AUTHENTICATION_FAILED (constant-time compare aby nebyl timing attack) |
key obsahuje .. | POST /presign-upload je volán | 400 STORAGE_INVALID_KEY |
key začíná / | POST /presign-upload je volán | 400 STORAGE_INVALID_KEY |
key obsahuje // | POST /presign-upload je volán | 400 STORAGE_INVALID_KEY |
| key obsahuje NUL byte | POST /presign-upload je volán | 400 STORAGE_INVALID_KEY |
| key je 1025 znaků dlouhý | POST /presign-upload je volán | 400 STORAGE_INVALID_KEY |
| contentLength = 104 857 600 (100 MB) | POST /presign-upload je volán | 200 OK (boundary je inclusive) |
| contentLength = 104 857 601 (100 MB + 1B) | POST /presign-upload je volán | 400 STORAGE_OBJECT_TOO_LARGE |
| contentType chybí | POST /presign-upload je volán | 400 STORAGE_CONTENT_TYPE_REQUIRED |
quota_bytes IS NULL (default, unlimited), contentLength = 100 MB, bytes_used libovolné | POST /presign-upload je volán | 200 OK; quota check je skipped (full-skip path), žádný roundtrip do platform BE |
quota_bytes = 10 GB, bytes_used = 0, contentLength = 100 MB | POST /presign-upload je volán | 200 OK |
quota_bytes = 1 GB, bytes_used = 950 MB, contentLength = 100 MB | POST /presign-upload je volán | 429 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án | 429 STORAGE_QUOTA_EXCEEDED |
quota_bytes IS NOT NULL, platform internal quota endpoint je unreachable | storage-svc startuje, žádná cache | fail-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án | 500 STORAGE_PRESIGN_FAILED; log ERROR |
| validní upload URL, klient PUT na R2 | klient uploaduje za 14 minut od presign | 200 OK od R2 (URL stále platná) |
| validní upload URL, klient PUT na R2 | klient uploaduje za 16 minut od presign | 403 Forbidden od R2 (URL expired) |
| presign-upload pak ihned klient PUT s ContentType odlišným od presignu | klient uploaduje | 403 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.
Thanks for the feedback.