- Implemented main entry point for React application in `main.jsx`. - Created `Gallery` component for displaying images with infinite scroll and pull-to-refresh functionality. - Developed `ImageDetail` component for showing detailed information and actions for selected images. - Added `Settings` component for configuring API connection settings. - Integrated Vite for build and development with PWA support. - Implemented caching strategies for API responses and image previews using Workbox.
487 lines
18 KiB
Lua
487 lines
18 KiB
Lua
-- Route handlers. Each function receives a request table and returns:
|
|
-- status (int), body (string), content_type (string|nil), extra_headers (table|nil)
|
|
local json = require "json"
|
|
local auth = require "auth"
|
|
local db = require "db"
|
|
local preview = require "preview"
|
|
local config = require "config"
|
|
local modules = require "modules"
|
|
local socket = require "socket"
|
|
|
|
-- ── Refresh queue (IPC with the darktable bridge plugin) ──────────────────────
|
|
-- The bridge plugin running inside darktable polls this file and calls
|
|
-- image:drop_cache() + image:generate_cache() for each queued image ID.
|
|
--
|
|
-- We append atomically via a .tmp + rename idiom to avoid partial reads:
|
|
-- 1. Write IDs to <queue>.tmp.<pid>
|
|
-- 2. Append that file's contents to the main queue (or rename if queue absent)
|
|
-- In practice, for low-frequency refresh calls a simple append is fine because
|
|
-- the bridge reads and renames the queue file atomically.
|
|
local function enqueue_refresh(image_id)
|
|
local qf = config.refresh_queue
|
|
local f, ferr = io.open(qf, "a")
|
|
if not f then
|
|
return false, "cannot open refresh queue " .. qf .. ": " .. (ferr or "?")
|
|
end
|
|
f:write(tostring(image_id) .. "\n")
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local function enqueue_export(image_id)
|
|
local qf = config.export_queue
|
|
local f, ferr = io.open(qf, "a")
|
|
if not f then
|
|
return false, "cannot open export queue " .. qf .. ": " .. (ferr or "?")
|
|
end
|
|
f:write(tostring(image_id) .. "\n")
|
|
f:close()
|
|
return true
|
|
end
|
|
|
|
local function export_path(image_id)
|
|
return config.export_dir .. "/" .. tostring(image_id) .. ".jpg"
|
|
end
|
|
|
|
local M = {}
|
|
|
|
-- ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
local function ok(data, ct)
|
|
return 200, json.encode(data), ct or "application/json; charset=utf-8"
|
|
end
|
|
|
|
local function err(status, code, detail)
|
|
local body = json.encode({ error = code, detail = detail })
|
|
return status, body, "application/json; charset=utf-8"
|
|
end
|
|
|
|
-- Parse application/x-www-form-urlencoded or application/json body.
|
|
local function parse_body(req)
|
|
local ct = (req.headers["content-type"] or ""):lower()
|
|
if ct:find("application/json", 1, true) then
|
|
return json.decode(req.body or "{}")
|
|
end
|
|
-- form-encoded
|
|
local fields = {}
|
|
for k, v in (req.body or ""):gmatch("([^&=]+)=([^&]*)") do
|
|
k = k:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
|
|
v = v:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
|
|
fields[k] = v
|
|
end
|
|
return fields
|
|
end
|
|
|
|
-- Authenticate the request and check an optional scope.
|
|
-- Returns: payload on success.
|
|
-- Returns: nil, status, body, ct on failure (caller does "return s,b,ct").
|
|
local function require_auth(req, scope)
|
|
local payload, aerr = auth.authenticate(req)
|
|
if not payload then
|
|
return nil, err(401, "unauthorized", aerr)
|
|
end
|
|
if scope and not auth.require_scope(payload, scope) then
|
|
return nil, err(403, "forbidden", "scope '" .. scope .. "' required")
|
|
end
|
|
return payload
|
|
end
|
|
|
|
-- ── Public endpoints ──────────────────────────────────────────────────────────
|
|
|
|
-- GET /health — liveness check, no auth required
|
|
function M.health(req)
|
|
return ok({ status = "ok", timestamp = os.time() })
|
|
end
|
|
|
|
-- ── OAuth 2.0 ─────────────────────────────────────────────────────────────────
|
|
-- POST /oauth/token
|
|
-- Accepts: application/x-www-form-urlencoded or application/json
|
|
-- Required fields: grant_type=client_credentials, client_id, client_secret
|
|
-- Optional: scope (space-separated, must be subset of client's scopes)
|
|
--
|
|
-- Returns: { access_token, token_type, expires_in, scope }
|
|
function M.oauth_token(req)
|
|
local fields = parse_body(req)
|
|
if not fields then
|
|
return err(400, "invalid_request", "could not parse request body")
|
|
end
|
|
|
|
if fields.grant_type ~= "client_credentials" then
|
|
return err(400, "unsupported_grant_type",
|
|
"only 'client_credentials' is supported")
|
|
end
|
|
|
|
-- Also accept Basic Auth header for client credentials
|
|
local client_id, client_secret = fields.client_id, fields.client_secret
|
|
if not client_id then
|
|
local basic = (req.headers["authorization"] or ""):match("^[Bb]asic%s+(.+)$")
|
|
if basic then
|
|
local decoded = require("base64").decode(basic)
|
|
client_id, client_secret = decoded:match("^([^:]+):(.+)$")
|
|
end
|
|
end
|
|
|
|
if not client_id or not client_secret then
|
|
return err(400, "invalid_request", "client_id and client_secret are required")
|
|
end
|
|
|
|
local ok_flag, client_scopes = auth.validate_client(client_id, client_secret)
|
|
if not ok_flag then
|
|
return err(401, "invalid_client", client_scopes)
|
|
end
|
|
|
|
-- Restrict to requested scopes if provided
|
|
local granted = client_scopes
|
|
if fields.scope and fields.scope ~= "" then
|
|
local requested = {}
|
|
for s in fields.scope:gmatch("%S+") do requested[s] = true end
|
|
granted = {}
|
|
for _, s in ipairs(client_scopes) do
|
|
if requested[s] then granted[#granted+1] = s end
|
|
end
|
|
if #granted == 0 then
|
|
return err(400, "invalid_scope", "none of the requested scopes are available")
|
|
end
|
|
end
|
|
|
|
local token = auth.issue_token(client_id, granted)
|
|
return ok({
|
|
access_token = token,
|
|
token_type = "Bearer",
|
|
expires_in = config.token_ttl,
|
|
scope = table.concat(granted, " "),
|
|
})
|
|
end
|
|
|
|
-- ── Images ────────────────────────────────────────────────────────────────────
|
|
-- GET /api/v1/images
|
|
-- Query params: rating_min, rating_max, rating, film_id, from, to,
|
|
-- color_label, is_raw, is_hdr, search, tag,
|
|
-- limit (1-500, default 50), offset, sort_by, sort_dir
|
|
function M.list_images(req)
|
|
local payload, s, b, ct = require_auth(req, "images:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local result = db.list_images(req.params)
|
|
return ok(result)
|
|
end
|
|
|
|
-- GET /api/v1/images/:id
|
|
function M.get_image(req)
|
|
local payload, s, b, ct = require_auth(req, "images:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
return ok(img)
|
|
end
|
|
|
|
-- ── Previews ──────────────────────────────────────────────────────────────────
|
|
-- GET /api/v1/images/:id/preview[?size=thumb|small|medium|large|xlarge|0-5]
|
|
-- Returns JPEG data directly (Content-Type: image/jpeg).
|
|
function M.get_preview(req)
|
|
local payload, s, b, ct = require_auth(req, "previews:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
local data, ct, redirect = preview.get(id, img.path, img.filename, req.params.size)
|
|
|
|
if data then
|
|
return 200, data, ct
|
|
end
|
|
|
|
if redirect then
|
|
-- Attempt to serve the local file directly rather than issuing a redirect,
|
|
-- since file:// URLs are not useful to HTTP clients.
|
|
local file_data = preview.read_local(redirect)
|
|
if file_data then
|
|
local ext = (img.filename or ""):match("%.([^%.]+)$") or ""
|
|
local mime = ext:lower():match("^png$") and "image/png" or "image/jpeg"
|
|
return 200, file_data, mime
|
|
end
|
|
end
|
|
|
|
-- No cached preview exists. Enqueue a full-pipeline thumbnail generation
|
|
-- so the bridge renders one; the client should retry after a few seconds.
|
|
enqueue_refresh(id)
|
|
return 202, json.encode({
|
|
status = "generating",
|
|
image_id = id,
|
|
retry_after = 4,
|
|
message = "thumbnail is being generated; retry in a few seconds",
|
|
}), "application/json; charset=utf-8", { ["Retry-After"] = "4" }
|
|
end
|
|
|
|
-- ── Tags ──────────────────────────────────────────────────────────────────────
|
|
-- GET /api/v1/tags[?search=&limit=&offset=]
|
|
function M.list_tags(req)
|
|
local payload, s, b, ct = require_auth(req, "tags:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local tags = db.list_tags(req.params)
|
|
return ok({ items = tags })
|
|
end
|
|
|
|
-- ── Film rolls ────────────────────────────────────────────────────────────────
|
|
-- GET /api/v1/films[?search=&limit=&offset=]
|
|
function M.list_films(req)
|
|
local payload, s, b, ct = require_auth(req, "films:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local films = db.list_films(req.params)
|
|
return ok({ items = films })
|
|
end
|
|
|
|
-- ── Cache refresh ─────────────────────────────────────────────────────────────
|
|
-- POST /api/v1/images/:id/refresh
|
|
-- Enqueues the image for thumbnail regeneration via the darktable bridge.
|
|
-- The bridge calls image:drop_cache() + image:generate_cache(true, …), which
|
|
-- re-renders through darktable's full processing pipeline so that development
|
|
-- settings (tone curves, exposure, colour grading, etc.) are reflected.
|
|
-- Requires scope: cache:refresh
|
|
function M.refresh_cache(req)
|
|
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
local queued, qerr = enqueue_refresh(id)
|
|
if not queued then
|
|
return err(500, "queue_error", qerr)
|
|
end
|
|
|
|
return 202, json.encode({
|
|
status = "accepted",
|
|
image_id = id,
|
|
filename = img.filename,
|
|
queue = config.refresh_queue,
|
|
message = "cache refresh enqueued; darktable bridge will re-render thumbnails shortly",
|
|
}), "application/json; charset=utf-8"
|
|
end
|
|
|
|
-- ── Development modules ───────────────────────────────────────────────────────
|
|
-- GET /api/v1/images/:id/modules
|
|
-- Returns every operation in the history stack (active entry per operation).
|
|
-- Response includes decoded params for known modules; base64 blob for others.
|
|
-- Requires scope: images:read
|
|
function M.list_modules(req)
|
|
local payload, s, b, ct = require_auth(req, "images:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
local mods = db.list_modules(id)
|
|
return ok({ image_id = id, items = mods })
|
|
end
|
|
|
|
-- GET /api/v1/images/:id/modules/:op
|
|
-- Returns the active history entry for one operation.
|
|
-- Requires scope: images:read
|
|
function M.get_module(req)
|
|
local payload, s, b, ct = require_auth(req, "images:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
local op = req.path_params.op
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
local mod = db.get_module(id, op)
|
|
if not mod then
|
|
return err(404, "module_not_found",
|
|
"operation '" .. op .. "' not in history for image " .. id)
|
|
end
|
|
return ok(mod)
|
|
end
|
|
|
|
-- PATCH /api/v1/images/:id/modules/:op
|
|
-- Body (JSON):
|
|
-- {
|
|
-- "enabled": true|false, -- optional: change enabled state
|
|
-- "params": { field: value, … } -- optional: named params for known modules
|
|
-- "params_b64": "<base64>" -- alternative: raw binary for unknown modules
|
|
-- }
|
|
-- Query param: ?preview=thumb|small|medium|large|xlarge
|
|
-- If given, waits up to 30 s for the thumbnail to update and returns the JPEG.
|
|
-- Otherwise returns 204 No Content.
|
|
-- Requires scope: cache:refresh
|
|
function M.patch_module(req)
|
|
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
local op = req.path_params.op
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
local mod = db.get_module(id, op)
|
|
if not mod then
|
|
return err(404, "module_not_found",
|
|
"operation '" .. op .. "' not in history for image " .. id)
|
|
end
|
|
|
|
-- Parse request body
|
|
local body = parse_body(req)
|
|
if not body then
|
|
return err(400, "invalid_body", "could not parse JSON body")
|
|
end
|
|
|
|
local new_enabled = body.enabled -- may be nil (leave unchanged)
|
|
local new_blob = nil
|
|
|
|
if body.params_b64 then
|
|
-- Caller provided raw base64 params (for unknown modules)
|
|
local ok_dec, decoded = pcall(require("base64").decode, body.params_b64)
|
|
if not ok_dec then
|
|
return err(400, "invalid_params_b64", "base64 decode failed")
|
|
end
|
|
new_blob = decoded
|
|
elseif body.params then
|
|
if mod.params then
|
|
local existing_blob = db.get_module_blob(id, op)
|
|
if not existing_blob then
|
|
return err(500, "db_error", "could not read existing params blob")
|
|
end
|
|
new_blob = modules.encode(op, mod.module_version, existing_blob, body.params)
|
|
if not new_blob then
|
|
return err(400, "unsupported_module",
|
|
"named-param editing is not supported for operation '" .. op ..
|
|
"'; use params_b64 with a raw binary blob instead")
|
|
end
|
|
else
|
|
-- Unknown module, caller must supply params_b64
|
|
return err(400, "params_required",
|
|
"operation '" .. op .. "' requires params_b64 (binary blob) for editing")
|
|
end
|
|
end
|
|
|
|
-- Persist to history table
|
|
local ok_write, werr = db.set_module_params(id, op, new_enabled, new_blob)
|
|
if not ok_write then
|
|
return err(500, "db_write_error", werr)
|
|
end
|
|
|
|
-- Enqueue a thumbnail refresh so the bridge re-renders with the new params.
|
|
enqueue_refresh(id)
|
|
|
|
-- Optional synchronous preview response
|
|
local preview_size = req.params.preview
|
|
if preview_size and preview_size ~= "" then
|
|
-- Read the thumbnail before the refresh so we can detect when it changes.
|
|
local old_data = preview.get(id, img.path, img.filename, preview_size)
|
|
local deadline = os.time() + 30
|
|
local new_data
|
|
while os.time() < deadline do
|
|
socket.select(nil, nil, 0.5)
|
|
local candidate = preview.get(id, img.path, img.filename, preview_size)
|
|
if candidate and candidate ~= old_data then
|
|
new_data = candidate
|
|
break
|
|
end
|
|
end
|
|
if new_data then
|
|
return 200, new_data, "image/jpeg"
|
|
end
|
|
-- Timeout — return what we have (may be stale) with a warning header
|
|
local fallback = preview.get(id, img.path, img.filename, preview_size)
|
|
if fallback then
|
|
return 200, fallback, "image/jpeg",
|
|
{ ["X-Preview-Warning"] = "regeneration-timeout; thumbnail may be stale" }
|
|
end
|
|
return err(504, "preview_timeout",
|
|
"thumbnail regeneration timed out after 30 s; retry the preview endpoint")
|
|
end
|
|
|
|
return 204, "", nil
|
|
end
|
|
|
|
-- ── Full-resolution export ────────────────────────────────────────────────────
|
|
-- POST /api/v1/images/:id/export
|
|
-- Removes any stale export file, enqueues the bridge job, returns 202.
|
|
-- The client should poll GET /full until it receives a 200.
|
|
-- Requires scope: cache:refresh
|
|
function M.trigger_export(req)
|
|
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
-- Remove stale export so the client can detect when the fresh one lands.
|
|
os.remove(export_path(id))
|
|
|
|
local queued, qerr = enqueue_export(id)
|
|
if not queued then return err(500, "queue_error", qerr) end
|
|
|
|
return 202, json.encode({
|
|
status = "accepted",
|
|
image_id = id,
|
|
filename = img.filename,
|
|
message = "export enqueued; poll GET /api/v1/images/" .. id .. "/full",
|
|
}), "application/json; charset=utf-8"
|
|
end
|
|
|
|
-- GET /api/v1/images/:id/full
|
|
-- Serves the full-resolution JPEG previously exported via POST /export.
|
|
-- If no export exists yet, enqueues one and returns 202 so the client can retry.
|
|
-- Requires scope: previews:read
|
|
function M.get_full(req)
|
|
local payload, s, b, ct = require_auth(req, "previews:read")
|
|
if not payload then return s, b, ct end
|
|
|
|
local id = tonumber(req.path_params.id)
|
|
if not id then return err(400, "invalid_id", "id must be an integer") end
|
|
|
|
local img = db.get_image(id)
|
|
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
|
|
|
local path = export_path(id)
|
|
local f = io.open(path, "rb")
|
|
if f then
|
|
local data = f:read("*a"); f:close()
|
|
local stem = (img.filename or "export"):match("^(.+)%.[^.]+$") or (img.filename or "export")
|
|
local disposition = string.format('attachment; filename="%s.jpg"', stem)
|
|
return 200, data, "image/jpeg", { ["Content-Disposition"] = disposition }
|
|
end
|
|
|
|
-- Not yet exported — enqueue and tell the client to retry.
|
|
local queued, qerr = enqueue_export(id)
|
|
if not queued then
|
|
return err(500, "queue_error", qerr)
|
|
end
|
|
|
|
return 202, json.encode({
|
|
status = "queued",
|
|
image_id = id,
|
|
filename = img.filename,
|
|
retry_after = 5,
|
|
message = "export enqueued; retry in a few seconds",
|
|
}), "application/json; charset=utf-8", { ["Retry-After"] = "5" }
|
|
end
|
|
|
|
return M
|