Repository navigation
Media Core v1
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.
- 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>/); devphp -S— via a file_exists guard inrouter.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.htaccessinuploads/disabling script execution + an immutable cache. File limit — 10 MB. -
.gitignore:/uploads/*+!/uploads/.gitkeep— same aslandings//offers/.
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).
- A new
mediaresource inorbitraResourceAccessMap()+ a tab inTAB_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/fullsee 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 anyfull(deleting a folder is NOT destructive: files stay, moving to the root).
| 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).
<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
sizeContractevery 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(inlandings.config_json) and bakes theurlinto the generated static files. The "URL world" — like LeadForge.
-
OfferEditor / LandingEditor: a "Replace from gallery" button next to the existing
"Replace image" → picker →
fetch(url)→ blob → the existingupload_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}">').
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.
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).
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).
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.
Вики собирается из папки docs/ репозитория. Правки — через pull request в docs/.
Getting started
Traffic
- Campaigns & streams
- Cloaking
- Landing pages
- Tracking Client (PHP)
- PWA & Web Push
- PWA Funnel
- Content gallery
Conversions & money
Analytics
Infrastructure
Management
For developers