Add darktable REST API implementation
- 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.
This commit is contained in:
208
contrib/darktable-api/server.lua
Normal file
208
contrib/darktable-api/server.lua
Normal file
@@ -0,0 +1,208 @@
|
||||
#!/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.post("/api/v1/images/:id/refresh", api.refresh_cache)
|
||||
|
||||
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", [204] = "No Content",
|
||||
[301] = "Moved Permanently",
|
||||
[400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden",
|
||||
[404] = "Not Found", [405] = "Method Not Allowed",
|
||||
[500] = "Internal Server Error",
|
||||
}
|
||||
|
||||
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, 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
|
||||
Reference in New Issue
Block a user