- Introduced a new Lua-based REST API for darktable, allowing external applications to interact with the darktable library. - Implemented core functionalities including image listing, retrieval, and preview serving. - Added support for OAuth 2.0 authentication with token generation. - Created a simple HTTP router to handle API requests. - Included JSON encoding/decoding utilities for API responses. - Developed a script to launch the API server alongside darktable, ensuring seamless integration. - Added necessary configuration and setup for the API server, including CORS support and error handling.
253 lines
9.9 KiB
Lua
253 lines
9.9 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"
|
|
|
|
-- ── 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 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
|
|
|
|
return err(404, "no_preview", "no cached preview available for image " .. id)
|
|
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 an image ID for cache regeneration by the darktable bridge plugin.
|
|
-- The bridge plugin (darktable-api-bridge.lua) running inside a live darktable
|
|
-- instance calls image:drop_cache() + image:generate_cache() when it drains
|
|
-- the queue. Returns 202 Accepted immediately; the actual regeneration happens
|
|
-- asynchronously inside darktable.
|
|
-- 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
|
|
|
|
-- Verify the image exists in the catalog before queuing
|
|
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 process it shortly",
|
|
}), "application/json; charset=utf-8"
|
|
end
|
|
|
|
return M
|