libs/neocities-api.lua
1-- {{{ neocities-api.lua
2-- Thin, status-honest HTTP layer over the Neocities API, via curl. The stock
3-- `neocities` gem throws the HTTP status away and JSON.parse-crashes on any
4-- non-JSON body (a Cloudflare/timeout HTML page), which is why a big push dies
5-- instead of backing off. This layer instead returns the REAL status code and a
6-- parsed-or-nil body for every call, so the adaptive controller (neocities-sync)
7-- can tell "too big" (shrink) from "rate limited" (wait) from "ok".
8--
9-- Verified request shapes (confirmed against the live API before writing this):
10-- list : GET /api/list?path=... -> { files=[...] }
11-- upload : POST /api/upload, multipart, field NAME = remote path, value = @file
12-- delete : POST /api/delete, repeated form field filenames[] = remote path
13--
14-- Multi-file requests use a curl CONFIG FILE (-K) rather than a shell command
15-- line, so paths never go through shell quoting (and the API key never appears in
16-- a process argv). The config file lives in tmp/ (RAM) and is rewritten per call.
17-- }}}
18
19local M = {}
20local dkjson = require("dkjson")
21
22local API = "https://neocities.org/api/"
23local api_key = nil
24local project_root = "/mnt/mtwo/programming/ai-stuff/neocities-modernization"
25
26-- {{{ M.set_key / M.set_project_root
27function M.set_key(k) api_key = k end
28function M.set_project_root(dir) if dir and dir ~= "" then project_root = dir end end
29-- }}}
30
31-- {{{ local function cfg_quote(s)
32-- Escape a value for a curl config-file double-quoted string. Output paths are
33-- clean (generated HTML), but escape backslash and quote defensively anyway.
34local function cfg_quote(s)
35 return '"' .. tostring(s):gsub('\\', '\\\\'):gsub('"', '\\"') .. '"'
36end
37-- }}}
38
39-- {{{ local function run_curl(config_lines)
40-- Write the curl options to a RAM config file, run curl, and split the trailing
41-- "__HTTP__<code>" sentinel (added via write-out) off the body. Returns
42-- (status:number, body:string). status 0 means curl itself failed (transport).
43local function run_curl(config_lines)
44 local path = project_root .. "/tmp/neocities-curl.cfg"
45 local f = assert(io.open(path, "w"), "neocities-api: cannot write " .. path)
46 f:write("silent\n")
47 f:write("show-error\n")
48 f:write('header = "Authorization: Bearer ' .. (api_key or "") .. '"\n')
49 f:write('write-out = "\\n__HTTP__%{http_code}"\n')
50 for _, line in ipairs(config_lines) do f:write(line .. "\n") end
51 f:close()
52
53 local h = io.popen("curl -K " .. cfg_quote(path) .. " 2>/dev/null")
54 local out = h and h:read("*a") or ""
55 if h then h:close() end
56
57 local status = tonumber(out:match("__HTTP__(%d+)%s*$")) or 0
58 local body = out:gsub("%s*__HTTP__%d+%s*$", "")
59 return status, body
60end
61-- }}}
62
63-- {{{ local function result(status, body)
64-- Shape a response for the controller: ok_body is true only on 200 with a
65-- parseable JSON body whose result is "success".
66local function result(status, body)
67 local ok_body = false
68 if status == 200 then
69 local parsed = dkjson.decode(body or "")
70 ok_body = (type(parsed) == "table" and parsed.result == "success") or false
71 end
72 return { status = status, ok_body = ok_body, body = body }
73end
74-- }}}
75
76-- {{{ function M.list(path)
77-- GET the file list under `path` (or the whole site if nil). Returns the parsed
78-- `files` array (each { path=, is_directory=, sha1_hash=, size= }), or nil+err.
79function M.list(path)
80 local lines = { 'url = ' .. cfg_quote(API .. "list" .. (path and ("?path=" .. path) or "")) }
81 local status, body = run_curl(lines)
82 if status ~= 200 then return nil, "list HTTP " .. status end
83 local parsed = dkjson.decode(body or "")
84 if type(parsed) ~= "table" or not parsed.files then
85 return nil, "list: unparseable response (HTTP " .. status .. ")"
86 end
87 return parsed.files
88end
89-- }}}
90
91-- {{{ function M.upload_batch(items)
92-- items: array of { remote=<site path>, abspath=<local file> }. One multipart
93-- POST carrying every file (field name = remote path, value = @localfile).
94-- Returns { status, ok_body } for the controller.
95function M.upload_batch(items)
96 local lines = {
97 'url = ' .. cfg_quote(API .. "upload"),
98 'max-time = 300',
99 'connect-timeout = 20',
100 }
101 for _, it in ipairs(items) do
102 lines[#lines + 1] = 'form = ' .. cfg_quote(it.remote .. "=@" .. it.abspath)
103 end
104 return result(run_curl(lines))
105end
106-- }}}
107
108-- {{{ function M.delete_batch(paths)
109-- paths: array of remote site paths. One POST with repeated filenames[] fields.
110function M.delete_batch(paths)
111 local lines = {
112 'url = ' .. cfg_quote(API .. "delete"),
113 'max-time = 120',
114 'connect-timeout = 20',
115 }
116 for _, p in ipairs(paths) do
117 lines[#lines + 1] = 'form = ' .. cfg_quote("filenames[]=" .. p)
118 end
119 return result(run_curl(lines))
120end
121-- }}}
122
123return M
124