MishkaCmsCore.Runtime.Media (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Manages media files (images, videos, documents) in the MishkaCMS runtime system with full versioning support.

This resource represents media files that can be uploaded and managed within the CMS. Each media file belongs to a specific site and can be categorized. The resource supports various media types including images, videos, and documents with metadata tracking such as dimensions, file format, size, and featured status.

Media files feature full version control through PaperTrail integration, tracking all changes with snapshot-based history and the ability to restore previous versions. The resource implements soft deletion via archival and provides comprehensive metadata management including dimensions, file type, format, and categorization.

Database-level constraints ensure data integrity with name validation, size checks, and prevention of modifications to archived media. Multi-tenant isolation ensures media files are properly segregated between sites. The resource provides extensive API actions for external integrations and maintains complete audit trails of all modifications including actor tracking and action recording.

The admin table's preview cell paints a type glyph behind the thumbnail and names the admin bundle's MediaThumb hook, which hides an image whose file has gone so the glyph is what you see. The onerror= attribute that used to do it is refused by an enforced Content-Security-Policy, which has no nonce or hash for an event handler. See MishkaGervaz.Table.Templates.MediaGallery.

Summary

Functions

Apply the full GuardedStruct pipeline (sanitize → validate → derive → main_validator) to attrs and return either {:ok, transformed_attrs} or {:error, errors}. Wire this into an Ash.Resource.Change to plug guardedstruct rules into Ash's changeset pipeline.

O(1) lookup of a field's compile-time metadata by name. Returns nil for unknown fields.

Compile-time-baked MapSet of every field name owned by the guardedstruct block. Used by GuardedStruct.AshResource.Change to decide, in O(1), whether a key in changeset.atomics belongs to the pipeline.

Moves an archived row's file into uploads_archived/, where the endpoint does not serve it — a file of the site site_id's own folder only (see storage_paths/2). Any other path answers {:error, :not_own_file} and is left alone.

Removes a media file for good, live or archived — a file of the site site_id's own folder only (see storage_paths/2). Any other path answers {:error, :not_own_file} and is left alone.

Each of records' folders as %{id, name}, name the whole path to it (photos / 2024), or nil for a file at the top level — read once for all of them, as MishkaCmsCore.Runtime.MediaCategory.paths/2 reads, with opts' actor.

An archived file as a data: URL, the only way the admin can still show it: uploads_archived/ is not served. nil when the file is not there, or is not a file of the site site_id's own folder (see storage_paths/2).

Validates that the keys in the provided input are valid for at least one action on the resource.

Same as input/1, except restricts the keys to values accepted by the action provided.

Puts the upload file_info in place of the file existing_relative_path names, at the same path, or beside it under the new extension when the format changed.

The file a form's upload entry names in its path, when it is one a form's upload was staged as.

The host's static folder, which /uploads and the other public files are served from: the :media_static_dir setting of :mishka_cms_core, priv/static by default.

Where the media file relative_path of the site site_id is on disk: :active under uploads/, :archived under uploads_archived/, and :relative, its URL with any . and .. worked out.

Moves a restored row's file back to uploads/; the counterpart of archive_file/2.

The name an upload of filename carries into the create: its basename without the extension, stripped of everything but word characters, spaces and dashes, spaces turned to dashes, downcased and cut to 200 characters. The action's own slugify(:name, into: :name) runs after this and has the last word on what the row holds.

Checks an uploaded file before it is stored: its extension is one of the allowed ones, it is at most 50 MB, and its first bytes are the kind the extension names — see MishkaCmsCore.Runtime.FileSignature.

Types

t()

@type t() :: %MishkaCmsCore.Runtime.Media{
  __lateral_join_source__: term(),
  __meta__: term(),
  __metadata__: term(),
  __order__: term(),
  aggregates: term(),
  archived_at: term(),
  archived_data_url: term(),
  calculations: term(),
  description: term(),
  dimensions: term(),
  featured: term(),
  folder: term(),
  format: term(),
  id: term(),
  inserted_at: term(),
  latest_version: term(),
  master_media_category: term(),
  media_category: term(),
  media_category_id: term(),
  name: term(),
  paper_trail_versions: term(),
  recent_versions: term(),
  relative_path: term(),
  site: term(),
  site_id: term(),
  size: term(),
  sorted_versions: term(),
  type: term(),
  updated_at: term(),
  version_count: term(),
  version_preview: term()
}

Functions

__guarded_change__(attrs, error? \\ false)

Apply the full GuardedStruct pipeline (sanitize → validate → derive → main_validator) to attrs and return either {:ok, transformed_attrs} or {:error, errors}. Wire this into an Ash.Resource.Change to plug guardedstruct rules into Ash's changeset pipeline.

The function is named __guarded_change__ because it does more than validate — it can also transform values (trim, downcase, slugify, auto-fill, etc.) before they reach the data layer.

__guarded_field_meta__(name)

O(1) lookup of a field's compile-time metadata by name. Returns nil for unknown fields.

__guarded_field_name_set__()

Compile-time-baked MapSet of every field name owned by the guardedstruct block. Used by GuardedStruct.AshResource.Change to decide, in O(1), whether a key in changeset.atomics belongs to the pipeline.

archive_file(relative_path, site_id)

Moves an archived row's file into uploads_archived/, where the endpoint does not serve it — a file of the site site_id's own folder only (see storage_paths/2). Any other path answers {:error, :not_own_file} and is left alone.

default_short_name()

delete_file_from_storage(relative_path, site_id)

Removes a media file for good, live or archived — a file of the site site_id's own folder only (see storage_paths/2). Any other path answers {:error, :not_own_file} and is left alone.

folders(records, opts)

@spec folders([map()], keyword()) :: [%{id: Ecto.UUID.t(), name: String.t()} | nil]

Each of records' folders as %{id, name}, name the whole path to it (photos / 2024), or nil for a file at the top level — read once for all of them, as MishkaCmsCore.Runtime.MediaCategory.paths/2 reads, with opts' actor.

get_archived_image_data_uri(relative_path, site_id, format)

An archived file as a data: URL, the only way the admin can still show it: uploads_archived/ is not served. nil when the file is not there, or is not a file of the site site_id's own folder (see storage_paths/2).

get_content_type(format)

input(opts)

@spec input(values :: map() | Keyword.t()) :: map() | no_return()

Validates that the keys in the provided input are valid for at least one action on the resource.

Raises a KeyError error at compile time if not. This exists because generally a struct should only ever be created by Ash as a result of a successful action. You should not be creating records manually in code, e.g %MyResource{value: 1, value: 2}. Generally that is fine, but often with embedded resources it is nice to be able to validate the keys that are being provided, e.g

Resource
|> Ash.Changeset.for_create(:create, %{embedded: EmbeddedResource.input(foo: 1, bar: 2)})
|> Ash.create()

input(opts, action)

@spec input(values :: map() | Keyword.t(), action :: atom()) :: map() | no_return()

Same as input/1, except restricts the keys to values accepted by the action provided.

json_api_match_route(_, _)

primary_key_matches?(left, right)

replace_file_in_storage(file_info, existing_relative_path, site_id)

Puts the upload file_info in place of the file existing_relative_path names, at the same path, or beside it under the new extension when the format changed.

{:error, :not_own_file} when that path is not a file of the site site_id's own folder — see storage_paths/2 — and nothing is written: the caller stores the upload as a new file instead.

save_file_to_storage(map, site_id, category_id)

staged_upload(path, site_id)

@spec staged_upload(term(), term()) :: {:ok, Path.t()} | :error

The file a form's upload entry names in its path, when it is one a form's upload was staged as.

MishkaGervaz consumes each upload of a submitted form into System.tmp_dir!() as gervaz_<n>_<client name> and hands the action that path in media_files (MishkaGervaz.Form.Web.Events.SubmitHandler). But media_files is a map a request can carry too — as a form field, or as a JSON:API attribute — and the file it names is copied into the site's folder and then deleted. So only a regular file straight inside that folder and named that way is taken, .. worked out first; a symbolic link, a folder, and every other path answer :error, are announced on cms_core:media:path_refused as op :source with the row's site_id, and are left untouched.

static_dir()

@spec static_dir() :: Path.t()

The host's static folder, which /uploads and the other public files are served from: the :media_static_dir setting of :mishka_cms_core, priv/static by default.

storage_paths(arg1, site_id)

@spec storage_paths(term(), term()) ::
  {:ok, %{relative: String.t(), active: Path.t(), archived: Path.t()}} | :error

Where the media file relative_path of the site site_id is on disk: :active under uploads/, :archived under uploads_archived/, and :relative, its URL with any . and .. worked out.

:error unless it is a file inside that site's own media folder, /uploads/<site_id>/media/: a path naming another site, another folder of the same site, or climbing out with .. or through a symbolic link is refused, and so is one naming a folder — live or archived — rather than a file, which a move or a rename would carry off whole, with every other row's file in it. Every function here that writes, reads, moves or deletes a media file asks this first, with the row's own site_id, and touches nothing when it is refused.

unarchive_file(relative_path, site_id)

Moves a restored row's file back to uploads/; the counterpart of archive_file/2.

upload_name(filename)

@spec upload_name(String.t()) :: String.t()

The name an upload of filename carries into the create: its basename without the extension, stripped of everything but word characters, spaces and dashes, spaces turned to dashes, downcased and cut to 200 characters. The action's own slugify(:name, into: :name) runs after this and has the last word on what the row holds.

Public because that name is the row's own key — unique_name_per_site — so anything asking whether a file is already in a site's library has to ask under the name an upload of it would produce. MishkaCmsCore.Dev.SeedMedia is the caller: working the rule out a second time over there would be a second answer that could disagree with this one.

validate_and_process_file(map)

@spec validate_and_process_file(%{file: term(), original_filename: String.t()}) ::
  {:ok, map()} | {:error, term()}

Checks an uploaded file before it is stored: its extension is one of the allowed ones, it is at most 50 MB, and its first bytes are the kind the extension names — see MishkaCmsCore.Runtime.FileSignature.

Pass %{file: file, original_filename: filename}. Answers {:ok, info} with the name, format, type, size, extension and source_path the row is made from, or {:error, error} naming what was wrong with the file.