Files
lua-scripts-w-api/contrib/darktable-api/server.lua
stephen schuresko 0831ee943c feat(api): enhance image module management and export functionality
- Added read-write access to the darktable SQLite catalog for module parameter edits.
- Implemented new endpoints for exporting images and retrieving full-resolution images.
- Introduced PATCH method for updating module parameters with optional preview.
- Added comprehensive tests for new API functionalities, including module retrieval and error handling.
- Updated CORS headers to support new HTTP methods.
- Created detailed documentation for new API endpoints and module operations.
2026-06-10 19:50:56 -04:00

214 lines
7.1 KiB
Lua

#!/usr/bin/env luajit
-- Darktable REST API — standalone LuaJIT HTTP server
-- Usage: luajit server.lua [port] [host]
--
-- Dependencies (install via luarocks):
-- luasocket — luarocks install luasocket
-- lsqlite3 — luarocks install lsqlite3
-- libcrypto — provided by the system OpenSSL package
--
-- Quick-start:
-- export DT_CLIENT_ID=myclient DT_CLIENT_SECRET=mysecret DT_JWT_SECRET=longrandomstring
-- luajit server.lua
--
-- Then get a token:
-- curl -s -X POST http://127.0.0.1:7645/oauth/token \
-- -d 'grant_type=client_credentials&client_id=myclient&client_secret=mysecret'
--
-- And use it:
-- curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:7645/api/v1/images
-- ── Bootstrap: make local modules findable ────────────────────────────────────
local script_dir = debug.getinfo(1, "S").source:match("^@(.+)/[^/]+$") or "."
package.path = script_dir .. "/?.lua;" .. package.path
-- ── Load modules ──────────────────────────────────────────────────────────────
local socket = require "socket"
local config = require "config"
local router = require "router"
local api = require "api"
-- ── Register routes ───────────────────────────────────────────────────────────
router.post("/oauth/token", api.oauth_token)
router.get("/health", api.health)
router.get("/api/v1/images", api.list_images)
router.get("/api/v1/images/:id", api.get_image)
router.get("/api/v1/images/:id/preview", api.get_preview)
router.get("/api/v1/images/:id/full", api.get_full)
router.post("/api/v1/images/:id/refresh", api.refresh_cache)
router.post("/api/v1/images/:id/export", api.trigger_export)
router.get("/api/v1/images/:id/modules", api.list_modules)
router.get("/api/v1/images/:id/modules/:op", api.get_module)
router.patch("/api/v1/images/:id/modules/:op", api.patch_module)
router.get("/api/v1/tags", api.list_tags)
router.get("/api/v1/films", api.list_films)
-- ── HTTP helpers ──────────────────────────────────────────────────────────────
local STATUS_TEXT = {
[200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content",
[301] = "Moved Permanently",
[400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden",
[404] = "Not Found", [405] = "Method Not Allowed",
[500] = "Internal Server Error", [504] = "Gateway Timeout",
}
local function url_decode(s)
return s:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
end
-- Parse the raw HTTP request from a connected socket.
-- Returns a request table, or nil on parse/timeout error.
local function read_request(client)
client:settimeout(10)
local line, lerr = client:receive("*l")
if not line then return nil end
local method, raw_path = line:match("^(%S+)%s+(%S+)%s+HTTP/")
if not method then return nil end
-- Headers
local headers = {}
while true do
local hline, herr = client:receive("*l")
if not hline or hline == "" then break end
local name, value = hline:match("^([^:]+):%s*(.*)$")
if name then
-- normalise to lowercase, strip trailing whitespace
headers[name:lower():gsub("%s+$", "")] = value:gsub("%s+$", "")
end
end
-- Body
local body = ""
local clen = tonumber(headers["content-length"])
if clen and clen > 0 then
body = client:receive(math.min(clen, 4 * 1024 * 1024)) or "" -- 4 MiB cap
end
-- Split path from query string
local path, qs = raw_path:match("^([^?]*)%??(.*)")
-- Parse query parameters
local params = {}
for k, v in (qs or ""):gmatch("([^&=]+)=([^&]*)") do
params[url_decode(k)] = url_decode(v)
end
return {
method = method:upper(),
path = path,
raw_path = raw_path,
headers = headers,
body = body,
params = params,
path_params = {},
}
end
local CORS_HEADERS = {
["Access-Control-Allow-Origin"] = "*",
["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS",
["Access-Control-Allow-Headers"] = "Authorization, Content-Type",
["Access-Control-Max-Age"] = "86400",
}
-- Write an HTTP response to the socket.
local function write_response(client, status, body, content_type, extra_headers)
body = body or ""
content_type = content_type or "application/json; charset=utf-8"
local phrase = STATUS_TEXT[status] or "Unknown"
local lines = {
string.format("HTTP/1.1 %d %s\r\n", status, phrase),
"Server: darktable-api/1.0\r\n",
"Content-Type: " .. content_type .. "\r\n",
"Content-Length: " .. #body .. "\r\n",
"Connection: close\r\n",
}
for k, v in pairs(CORS_HEADERS) do
lines[#lines+1] = k .. ": " .. v .. "\r\n"
end
if extra_headers then
for k, v in pairs(extra_headers) do
lines[#lines+1] = k .. ": " .. v .. "\r\n"
end
end
lines[#lines+1] = "\r\n"
lines[#lines+1] = body
client:send(table.concat(lines))
end
local json = require "json"
local function handle_client(client)
local req = read_request(client)
if not req then
write_response(client, 400, '{"error":"bad_request"}')
return
end
-- CORS preflight
if req.method == "OPTIONS" then
write_response(client, 204, "")
return
end
local handler, path_params = router.match(req.method, req.path)
if not handler then
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
return
end
req.path_params = path_params or {}
local status, body, ct, extra = handler(req)
write_response(client, status, body, ct, extra)
end
-- ── Main server loop ──────────────────────────────────────────────────────────
local port = tonumber(arg and arg[1]) or config.port
local host = (arg and arg[2]) or config.host
local server, serr = socket.bind(host, port)
if not server then
io.stderr:write("darktable-api: cannot bind " .. host .. ":" .. port .. ": " .. tostring(serr) .. "\n")
os.exit(1)
end
server:settimeout(0) -- non-blocking accept
io.write(string.format(
"darktable-api: listening on http://%s:%d\n" ..
" library : %s\n" ..
" cache : %s\n",
host, port, config.library_db, config.cache_dir))
io.flush()
while true do
local client, cerr = server:accept()
if client then
local ok, result = pcall(handle_client, client)
if not ok then
pcall(write_response, client, 500,
json.encode({ error = "internal_server_error", detail = tostring(result) }))
io.stderr:write("darktable-api: error: " .. tostring(result) .. "\n")
end
pcall(function() client:close() end)
else
-- No connection yet — yield CPU briefly.
socket.select(nil, nil, 0.05)
end
end