- 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.
281 lines
9.4 KiB
Lua
281 lines
9.4 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"
|
|
|
|
-- ── Static file server (PWA) ──────────────────────────────────────────────────
|
|
|
|
local WEB_BUILD = script_dir .. "/web/build"
|
|
|
|
local MIME = {
|
|
html = "text/html; charset=utf-8",
|
|
js = "application/javascript; charset=utf-8",
|
|
mjs = "application/javascript; charset=utf-8",
|
|
css = "text/css; charset=utf-8",
|
|
json = "application/json",
|
|
webmanifest = "application/manifest+json",
|
|
png = "image/png",
|
|
jpg = "image/jpeg",
|
|
jpeg = "image/jpeg",
|
|
svg = "image/svg+xml",
|
|
ico = "image/x-icon",
|
|
woff = "font/woff",
|
|
woff2 = "font/woff2",
|
|
txt = "text/plain; charset=utf-8",
|
|
map = "application/json",
|
|
}
|
|
|
|
-- Read a file from the web build directory. Returns data, content-type or nil.
|
|
-- Prevents directory traversal by rejecting any path containing "..".
|
|
local function read_static(req_path)
|
|
if req_path:find("%.%.", 1, true) then return nil end
|
|
|
|
-- Strip leading slash; normalise double-slashes.
|
|
local rel = req_path:gsub("^/+", ""):gsub("//+", "/")
|
|
local full = WEB_BUILD .. "/" .. rel
|
|
|
|
local f = io.open(full, "rb")
|
|
if not f then return nil end
|
|
local data = f:read("*a")
|
|
f:close()
|
|
|
|
local ext = rel:match("%.([^%.]+)$") or ""
|
|
local ct = MIME[ext:lower()] or "application/octet-stream"
|
|
return data, ct
|
|
end
|
|
|
|
-- Cache headers: hashed assets get long TTL; everything else no-store.
|
|
local function cache_header(path)
|
|
-- Vite fingerprints assets with a hash in the filename (e.g. index-Bx3Rk.js)
|
|
if path:match("/assets/") then
|
|
return "public, max-age=31536000, immutable"
|
|
end
|
|
return "no-store"
|
|
end
|
|
|
|
-- ── 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
|
|
|
|
-- API / OAuth routes take priority over static files.
|
|
local handler, path_params = router.match(req.method, req.path)
|
|
if handler then
|
|
req.path_params = path_params or {}
|
|
local status, body, ct, extra = handler(req)
|
|
write_response(client, status, body, ct, extra)
|
|
return
|
|
end
|
|
|
|
-- Static file serving for the PWA (GET/HEAD only).
|
|
if req.method == "GET" or req.method == "HEAD" then
|
|
local data, ct = read_static(req.path)
|
|
|
|
if not data then
|
|
-- SPA fallback: any unknown path gets index.html so client-side routing works.
|
|
data, ct = read_static("/index.html")
|
|
end
|
|
|
|
if data then
|
|
local extra = { ["Cache-Control"] = cache_header(req.path) }
|
|
write_response(client, 200, req.method == "HEAD" and "" or data, ct, extra)
|
|
return
|
|
end
|
|
end
|
|
|
|
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
|
|
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" ..
|
|
" web app : http://%s:%d/\n",
|
|
host, port, config.library_db, config.cache_dir, host, port))
|
|
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
|