# File storage, Studio folders and static site folders

> **Резюме.** Три разных механизма: медиатека Studio хранит файлы UUID и
> коллекции-папки; папка-сайт хранит дерево путей в версионируемом релизе;
> `/site-files` хранит текстовые шаблоны фидов и служебных файлов. Их операции
> и права различаются. Ниже — действующий контракт, проверенный по коду.

**Status:** current · **Verified:** 2026-10-05 · **Serves:** `GET /docs/file-storage`

## Choose the right system

| Need | API | Identity |
|---|---|---|
| Studio/media assets and reusable folder organization | `/v1/storages/files`, `/v1/storages/collections` | UUID file; collection has `parent_id`, `mode:folder` |
| Whole static HTML/CSS/JS website inside a category | `/site-folders/{id}` | category `type:site`; files have relative paths in a release |
| Feed, ads.txt, verification, small template-generated page | `/site-files` | category + filename; body ≤64 KiB; owner/admin |

Studio collections may contain the same file in multiple folders. Removing a
collection-file link does not delete the file. `POST /v1/storages/files/{id}/rename`
changes `original_name`, preserving its URL and bytes. It does not rename a
site-folder path. Site folders have no separate persisted directory entity:
**empty folders** cannot be stored; create a file such as `pages/index.html`.

## Static site folder: create, upload, publish, restore

1. Create a category with `POST /categories`, body
   `{"item":{"name":"Demo","type":"site","is_published":false,"seo":{"slug":"demo"}}}`.
   Use the numeric `result.id`. Set a unique SEO slug;
   nested category slugs determine the public prefix. `site` cannot contain products.
2. `GET /site-folders/{id}` returns `section_id`, `document_id`,
   `active_release_id`, `contour`, `path`, `files:[{path,size,mime,sha256}]`, `limits`.
   Before the first release this is 200 with empty files, not a missing category.
3. For individual files, `POST /site-folders/{id}/releases`:
   `{"base":"active","files":[{"path":"index.html","sha256":"…","size":123}],"note":"…"}`.
   `base` is a STRING: `active`, `none`, or a release id as a string. New paths
   overlay the base; unmentioned files remain. `delete:["old/path"]` removes paths.
4. PUT bytes to each returned `upload[].upload_url`, without admin auth headers.
   Already-known hashes may be reused. Once ALL uploads finish, call
   `POST /site-folders/{id}/releases/{rid}/publish`. `validate` on the same release
   returns findings without publication. A failed publish does not change the active pointer.
   Publishing a release does not publish its category: enable it separately with
   `PUT /categories/{id} {"item":{"is_published":true}}`; the shop must also be visible.
5. ZIP alternative: `POST /site-folders/{id}/releases/zip?base=active&publish=1`,
   multipart field `file` (or raw ZIP). `base=none` REPLACES the whole site;
   `base=active` merges, replacing matching paths. One common outer directory is
   stripped, hidden metadata is ignored. Ask before replacing existing work.
6. Editing, renaming or removing files creates another release. Rename with the
   same SHA/size under the new path plus `delete:["old/path"]`. Update references
   in all affected HTML/CSS/JS yourself. A folder deletion enumerates its descendants.
7. `GET …/releases` gives the latest 50 versions. Read a manifest with
   `GET …/releases/{rid}`; source text with `GET …/releases/{rid}/file?path=…`.
   `POST …/rollback {"release_id":123}` reactivates a published version. A prod
   rollback rejects a draft-only version (`was_live:false`). `DELETE …/releases/{rid}`
   rejects versions active in either contour (`in_use:true`).

## Admin tree and history cleanup

Folder directories start collapsed and open on demand. Search finds nested files
without expanding the entire tree. Newly created or recreated directories also
start collapsed; refresh preserves manually opened directories.

The category Structure tab initially shows five releases. Use the version's
menu for a single activation or deletion, or **Manage history** to select
inactive versions and delete them after one confirmation. Activations remain
individual. Versions active in PROD or DEV cannot be selected; the server also
rejects their deletion. Bulk cleanup sends sequential DELETE requests with one
captured shop/user/contour context, including authentication retries. It stops at
the first failure and refreshes history and tariff usage. Already deleted
versions are not restored; the remaining selection survives an error and retry.
No automatic retention policy deletes existing history.

Deleting an HTML draft release is different from `POST /v1/dev/discard`.
Discard removes the DEV overlay and unreferenced draft uploads, but preserves
files referenced by retained HTML releases, including inactive DEV history.
Their bytes remain billed until those releases and other references are removed.
PROD hash reuse excludes files uploaded only in a DEV contour; DEV may reuse
its own draft uploads or shared PROD files. Reference creation, repointing and
orphan cleanup synchronize per shop, and activation cannot revive a concurrently
deleted release. Snapshot loading/restoration refuses stale concurrent release
references rather than committing a dangling active pointer.

## Public URLs and React exports

The shop and category must be published. `/prefix/` serves `index.html`;
`/prefix/docs/` serves `docs/index.html`; `/prefix/docs/topic.html` serves that file.
A directory without a trailing slash redirects to its canonical directory URL.
Relative links resolve against the HTML page's location. A missing path uses
`404.html` with HTTP 404 when supplied; otherwise it stays 404.

Upload a **built static export** of React/Vite, not source `src/`, node_modules or
a Node server. Scripts run and assets keep their relative paths. There is no
**SPA fallback** to root index.html for arbitrary history routes. Use hash routing,
or export an HTML entry at each known route. Draft site-folder preview is not
implemented: a draft-contour release does not change the live pointer. A site
folder runs as its own document, without the shop's block-builder header/menu;
include internal navigation in the uploaded website.

## Project size and tariff storage

`GET /site-folders/{id}` also returns `storage_usage`:
`{active_bytes,retained_bytes,retained_files,release_count}`. `active_bytes` sums
the paths in the selected contour's current manifest. `retained_bytes` sums the
actual sizes of distinct live **file_id** objects referenced by every retained
release of this document, including drafts; `retained_files` counts those objects.
`release_count` includes the entire history, even when no release is active.
Do not add the sizes of the latest 50 releases, or deduplicate by SHA alone:
multiple objects can have the same SHA. Older servers may omit this field;
missing means unavailable, not zero.

`GET /tariff/usage` is the existing authoritative total for the whole shop and
reconciles live uploaded shop files, including Studio/media, HTML/site folders,
history and DEV. A shared file is counted once globally. Project totals overlap
when projects share a file; completed files from partial uploads can occupy
space without being attached to a release. Personal files and external CDN
links are excluded. Removing a path creates history and does not free bytes
still referenced by another version or entity. Remove unused inactive history
and files through their normal operations to free space.

Folder JSON/ZIP imports use the same CreateFile/DoneFile `storage_bytes` gate.
An over-quota publish/validate returns `429 STORAGE_LIMIT_REACHED` with
`limit_code:storage_bytes`; it does not switch the active release. Successfully
completed earlier files in a failed batch can remain stored and billed.
Refresh usage after success or failure. Quota enforcement uses actual final
file size, including any storage image conversion; it is not a promise about
HTTP traffic, database size, CDN resize cache or browser demo storage.

## Images and cache

Images may stay inside the uploaded folder or use an existing public CDN URL.
There is no automatic rewrite of `<img>`/CSS URLs or generation of `srcset`.
For PNG/JPEG/WebP/AVIF, `/prefix/assets/photo.webp?w=640&q=80` redirects to the
common image service; SVG/GIF are served as originals. Generate appropriate
`srcset`/`sizes` in the website if responsive variants are required.
HTML is `no-cache` with a hash ETag; folder assets are `public, max-age=60`
with an ETag. The resize service caches an immutable storage-key/width/quality
variant separately. A new release changes bytes/hash; URLs retain their paths.

## Browser isolation, demo state and public forms

The folder response uses a CSP sandbox **without `allow-same-origin`**. It has
opaque origin `null`: scripts/modules/dialogs work, but cookies, `localStorage`,
`sessionStorage`, IndexedDB and service workers are unavailable. Wrap optional
storage access in `try/catch`. A memory-only demo cart works within a page but
does not survive reloads or navigation to another HTML document. For demo state
use a single document/hash navigation, or explicitly carry non-sensitive ids
and quantities in the URL; never put contacts, drafts, PATs or sessions there.
Persistent browser storage needs a separately isolated hosting origin; it is
not enabled by this contract. Do not add `allow-same-origin` on the shop origin.

The standalone document does not install the storefront's `VzRuntime`, native
cart/account session, form popup host or widget iframe bridge automatically.
Use ordinary absolute links for technical checkout/payment pages; those pages
must validate products/prices themselves. The demo cart is not a real order.

For a custom form, use the existing anonymous `GET /forms/{id}/public` and
`POST /form-leads` contract in `/docs/webcoding` §13.5. The opaque
origin lane accepts exactly these methods/paths with `credentials:"omit"`;
POST requires `Content-Type: application/json`. Do not send Authorization,
Cookie, X-Vizen-Contour or X-Vizen-Draft: their presence, including empty values,
is rejected. Preflight permits only Accept and Content-Type. Other endpoints
retain their ordinary CORS policy. `Origin:null` is not proof of shop ownership;
visibility, tenant-from-form, required fields, consent, spam, quotas and intake
are still enforced by the same public form handler. Forms requiring a resource
access grant cookie cannot be used through this anonymous lane.

Create the form and configure its sandbox/webhook in admin or a server-side
tool. A browser needs only the public form id and API origin, never a PAT or
webhook signing secret. Declare attribution fields (source_page/widget_id/
button_id/button_text and optional product/options) in the form's schema; unknown
keys are discarded. Collect consent explicitly and preserve inputs on failure.
Webhook consumers verify the raw-body HMAC and deduplicate by event_id: delivery
is at least once. Sequential identical submissions within a minute are normally
deduplicated; concurrent submissions do not have an atomic exactly-once guarantee.

## Actual limits

- Site: **2000 files**, **20 MiB per HTML file**, **100 MiB per other file**.
  `limits.html_bytes` is per file, not total HTML bytes. Raster uploads also pass
  Storage's image ceiling (`API_IMG_MAX_UPLOAD_MB`, default 10 MiB): effective
  limit is the smaller cap. Do not promise 100 MiB images merely from folder limits.
- Source editor reads at most **2 MiB**, although larger HTML can be uploaded.
- ZIP: 60 MiB compressed, 512 MiB expanded, 2000 archive entries.
- Relative paths ≤512 UTF-8 bytes; no traversal/control characters. Executables
  and server scripts are forbidden. Incoming paths differing only by case conflict.
- `index.html` is required. Zero-byte files are ignored by the release manifest;
  an empty release is rejected. Storage `done` rejects an empty file.

## Auth and contours

With a PAT, first call `GET /v1/account/token`. Check its actual `scopes`,
`contour`, `writes_to_live`, `warnings` and `capabilities` (`catalog_read`,
`catalog_write`, `storage_write`, `html_level3_allowed`). A preset name or
`publish_allowed` alone does not prove permission to upload/publish a folder.

Tenant comes only from the authenticated user's `MainCompanyID`, not supplied
`company_id`. JWT uses the admin session/contour. PAT must belong to a shop
owner/admin for site folders: read requires `catalog:read`; create/upload/validate/
publish/delete require **both** `catalog:write` and `storage:write`; rollback
requires `catalog:write`. Normal JWT callers also require owner/admin.
Dev PAT cannot delete even an inactive release: `403 PAT_CONTOUR_MISMATCH`.
An owner/admin cabinet session may remove inactive history through its usual
contour; versions active in either contour remain protected.

For Studio Storage, PAT `storage:read` permits file/collection reads, collection
search and reverse lookup. `storage:write` permits file create/done/replace/delete/
rename/prefer-original, and collection **create/add file only**. Collection edit,
delete, remove-file and reorder are NOT in the PAT allowlist and return
`PAT_METHOD_NOT_ALLOWED`. Live operational collection writes and file rename/delete
reject dev PAT; overlay-safe upload/done/replace remain contour-aware.
`/site-files` instead uses `site-files:read/write`, owner/admin, prod-only mutations.
MCP currently exposes scene tools; no site-folder or Studio collection tools.

Source anchors: `api/storage/storage.proto`; `internal/api/htmlrelease/{site_folder,
releasemap,paths,filetext,zip,publish,release_delete}.go`; `internal/core/domain/
site_folder.go`; `internal/api/storage/{done_file,rename_file}.go`; auth guard PAT registry.
