Skip to content

Media Core v1

Ruslan edited this page Oct 14, 2026 · 2 revisions

Owner: the "Content Gallery" session. The PWA session is a CONSUMER of the picker (icon/screenshots under the 512/192 contract); it does not create its own storage (uploads/pwa/ is banned — architecture split). Sync status: memory orbitra-content-gallery-assessment.

1. Storage

  • Files: uploads/media/<ab>/<hash12>.<ext> — <ab> = the first 2 chars of the hash (directory sharding), <hash12> = 12 hex of a random salt + a sha256 prefix of the content. The name is always generated by the server — there are no user-supplied paths.
  • Public URL: /uploads/media/<ab>/<hash12>.<ext> (an absolute path from the site root; works both from the panel and from public pages — LeadForge/PWA).
  • Serving: a real directory → Apache serves the static files directly (the same mechanism as offers/<id>/); dev php -S — via a file_exists guard in router.php. Anonymous access is mandatory: images are visible on public landings.
  • Security instead of a PHP serving route (a deviation from the original contract, deliberate): a hard extension whitelist webp, jpg, jpeg, png, gif (SVG banned — active content), server-generated names, getimagesize() as a hard gate (a non-image is rejected — stronger than string scans), an .htaccess in uploads/ disabling script execution + an immutable cache. File limit — 10 MB.
  • .gitignore: /uploads/* + !/uploads/.gitkeep — same as landings//offers/.

2. DB (migration 44 + base DDL for fresh installs)

CREATE TABLE IF NOT EXISTS media_folders (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    name          TEXT NOT NULL,                -- ≤ 50 символов
    owner_user_id INTEGER,                      -- кто создал (аудит)
    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS media_assets (
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
    owner_user_id INTEGER,                      -- кто загрузил
    folder_id     INTEGER,                      -- NULL = корень
    orig_name     TEXT NOT NULL DEFAULT '',
    stored_name   TEXT NOT NULL,                -- <ab>/<hash12>.<ext>
    sha256        TEXT NOT NULL DEFAULT '',     -- задел под дедуп v1.5
    mime          TEXT NOT NULL DEFAULT '',
    size          INTEGER NOT NULL DEFAULT 0,
    width         INTEGER,
    height        INTEGER,
    is_active     INTEGER NOT NULL DEFAULT 1,   -- мягкое удаление
    created_at    DATETIME DEFAULT CURRENT_TIMESTAMP,
    deleted_at    DATETIME
);
CREATE INDEX IF NOT EXISTS idx_media_assets_folder ON media_assets(folder_id, is_active);
CREATE INDEX IF NOT EXISTS idx_media_assets_owner  ON media_assets(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_media_assets_sha256 ON media_assets(sha256);

The migration is idempotent (CREATE IF NOT EXISTS inside the if ($schemaVersion < 44) block); fresh installs get the same tables from the base DDL. $LATEST_SCHEMA_VERSION = 44 (43 is taken by the PWA session — landings.config_json + pwa_* click beacons). RACE RULE: before editing config.php — git status config.php; whoever commits first owns the number; the second takes the next one and re-reads $LATEST_SCHEMA_VERSION (the PWA session took 43, the media core took 44).

3. Access model

  • A new media resource in orbitraResourceAccessMap() + a tab in TAB_PERMISSION_KEYS + a row in the UsersPage modal + role templates (media_buyer/video_editor — full, developer — read).
  • The library is SHARED (this is a team story: a designer uploads — buyers take): read/full see all files; the per-user filter is admins-only.
  • FILE mutations (move/delete/restore) — owner or admin only (WHERE owner = me; partial success is reported honestly: updated/denied). Folders are shared, managed by any full (deleting a folder is NOT destructive: files stay, moving to the root).

4. API (api.php, gated automatically through the resource map)

Case Method Input Output
media_list GET folder_id?, type=image, status=active|inactive, user_id? (admin), q?, page (50/page) {items:[{id,orig_name,url,mime,size,width,height,folder_id,owner_user_id,owner_username,created_at}], total, page, pages, users?} (users — admins)
media_upload POST multipart files[], folder_id? {items:[...], failed:[{name,reason}]}
media_op POST JSON {op:'move'|'delete'|'restore', ids:[], folder_id?} (move: folder_id null=root) {updated, denied}
media_folders GET — {items:[{id,name,asset_count}]} (active)
media_folder_op POST JSON {op:'create'|'rename'|'delete', id?, name?} {status, id?}

media_upload/media_op/media_folder_op → write; media_list/media_folders → read. Errors are key codes (message = media.err_*), not Russian strings (an i18n lesson).

5. Picker contract (shared component common/MediaPicker.jsx) — FOR THE PWA SESSION

<MediaPicker
    open={boolean}            // рендерится как modal-overlay (z:2000)
    onClose={() => {}}
    onSelect={({ id, url, width, height, mime }) => {}}  // ЕДИНСТВЕННЫЙ контракт результата
    accept=".webp,.jpg,.jpeg,.png,.gif"   // дефолт — картинки
    multiple={false}
    sizeContract={{ width: 512, height: 512, crop: true, label: '512×512' }}  // опционально
/>
  • Pick an existing asset OR upload in place (drag&drop + click, multi-upload).
  • With sizeContract every asset gets an indicator: 🟢 exact WxH match · 🟡 croppable (at least one side ≥ required) · 🔴 smaller than the contract (selection is blocked, the reason — in a tooltip).
  • With crop: true, after upload and via the "Crop" button a crop overlay opens (a drag/resize frame on canvas, "Apply and upload" / "Skip"). The crop result = a NEW asset (the original is kept); onSelect receives its dimensions and URL.
  • PWA stores the id (in landings.config_json) and bakes the url into the generated static files. The "URL world" — like LeadForge.

6. v1 consumers (the gallery)

  • OfferEditor / LandingEditor: a "Replace from gallery" button next to the existing "Replace image" → picker → fetch(url) → blob → the existing upload_offer_file / upload_landing_file (path=<current file>; the asset extension must match the replaced one — the same rule as a regular replacement). The ZIP world stays self-contained: a COPY lands in the offer's tree.
  • "Insert image" in the code editor toolbar (visible on .html/.htm): picker → codeEditorRef.insertText('<img src="{url}" alt="{orig_name}">').

7. The gallery page (GalleryPage.jsx, tab media)

The first slot of the grid is upload (click + drag&drop, multi-upload, a spinner while uploading). A row of folders (create/rename/delete, click = filter). Toolbar: status (Active/Inactive), type (All/Images), user (admin), search. Selection by click (up to 50): clear / Move / Delete|Restore. Pagination 50/page. Theming: only var(--color-*), page-*/form-*/btn classes.

8. i18n

All keys in the media.* namespace (+ nav.mediaGallery) — straight into all 7 locales (en, ru, uk, es, fr, de, zh). fr: NBSP before :/? per the file's style. No t('media.x', 'русский фолбек') — fallbacks must not leak (a check:i18n lesson).

9. Tests (tests/media_core_test.php)

Extracting the functions from api.php + in-memory SQLite (the active_campaign_guard_test pattern): the extension whitelist, the getimagesize gate, name generation (no user-supplied paths), soft delete/restore, folder deletion → files to the root, the owner gate on mutations (someone else's file cannot be touched), media_list filters. The HTTP surface is not tested in v1 (the cases are thin wrappers over the functions).

10. Out of scope for v1

sha256 dedup (the column exists, the logic does not), "where used" (a link registry), video (ffmpeg/exec unavailable on shared hosting), the PWA icon and LeadForge (consumers of the PWA session / v1.5), tags, nested folders, integration with the Archive page.

Clone this wiki locally