aboutsummaryrefslogtreecommitdiff
path: root/server.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'server.mjs')
-rw-r--r--server.mjs178
1 files changed, 176 insertions, 2 deletions
diff --git a/server.mjs b/server.mjs
index 5ae51a0..e783acc 100644
--- a/server.mjs
+++ b/server.mjs
@@ -1,9 +1,14 @@
import http from "node:http"
-import { readFile } from "node:fs/promises"
+import { readFile, mkdir, writeFile, rm } from "node:fs/promises"
import { createReadStream, existsSync } from "node:fs"
import { extname, join, normalize } from "node:path"
+import { execFile } from "node:child_process"
+import { randomUUID } from "node:crypto"
+import { promisify } from "node:util"
import mysql from "mysql2/promise"
+const execFileAsync = promisify(execFile)
+
const DB = {
host: process.env.DB_HOST || "127.0.0.1",
port: Number(process.env.DB_PORT || 3306),
@@ -17,6 +22,16 @@ const PORT = Number(process.env.PORT || 3100)
const PUBLIC = join(process.cwd(), "public")
const MIN_PATH = 3
+const STAGING_ROOT = process.env.STAGING_ROOT || join(process.cwd(), "staging")
+const PHOTOPRISM_BIN = process.env.PHOTOPRISM_BIN || "photoprism"
+const PHOTOPRISM_USER = process.env.PHOTOPRISM_USER || "photoprism"
+const PHOTOPRISM_HOME = process.env.PHOTOPRISM_HOME || "/srv/photoprism"
+const ORIGINALS_PATH = process.env.PHOTOPRISM_ORIGINALS_PATH || "/srv/photoprism/originals"
+const STORAGE_PATH = process.env.PHOTOPRISM_STORAGE_PATH || "/srv/photoprism/storage"
+const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_BYTES || 200 * 1024 * 1024)
+const MAX_UPLOAD_FILES = Number(process.env.MAX_UPLOAD_FILES || 20)
+const IMPORT_TIMEOUT_MS = Number(process.env.IMPORT_TIMEOUT_MS || 10 * 60 * 1000)
+
const pool = mysql.createPool(DB)
const THUMB_SIZE = "1280x1024_fit"
@@ -152,7 +167,7 @@ async function load() {
exits.set(pid, list)
}
- return { builtAt: new Date().toISOString(), photos: Object.fromEntries(byId), facets, photoOrder, byId, exits }
+ return { builtAt: new Date().toISOString(), total: photoOrder.length, photos: Object.fromEntries(byId), facets, photoOrder, byId, exits }
}
async function refresh() {
@@ -181,6 +196,163 @@ function json(res, code, data) {
res.end(JSON.stringify(data))
}
+const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+const SIGNATURES = [
+ { ext: "jpg", accepts: /\.jpe?g$/i, test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
+ { ext: "png", accepts: /\.png$/i, test: (b) => b.length > 8 && b.subarray(0, 8).equals(PNG_MAGIC) },
+ {
+ ext: "webp",
+ accepts: /\.webp$/i,
+ test: (b) => b.length > 12 && b.subarray(0, 4).toString("latin1") === "RIFF" && b.subarray(8, 12).toString("latin1") === "WEBP",
+ },
+]
+
+function sniff(buf) {
+ return SIGNATURES.find((s) => s.test(buf)) || null
+}
+
+function readBody(req, limit) {
+ return new Promise((resolve, reject) => {
+ const chunks = []
+ let size = 0
+ req.on("data", (c) => {
+ size += c.length
+ if (size > limit) {
+ const err = new Error("payload too large")
+ err.code = "TOO_LARGE"
+ reject(err)
+ req.destroy()
+ return
+ }
+ chunks.push(c)
+ })
+ req.on("end", () => resolve(Buffer.concat(chunks)))
+ req.on("error", reject)
+ })
+}
+
+function parseMultipart(buf, boundary) {
+ const parts = []
+ const delim = Buffer.from(`--${boundary}`)
+ const sep = Buffer.from("\r\n\r\n")
+ let pos = buf.indexOf(delim)
+ if (pos < 0) return parts
+ pos += delim.length
+ while (pos < buf.length) {
+ if (buf[pos] === 0x2d && buf[pos + 1] === 0x2d) break
+ if (buf[pos] === 0x0d && buf[pos + 1] === 0x0a) pos += 2
+ const headEnd = buf.indexOf(sep, pos)
+ if (headEnd < 0) break
+ const headers = buf.subarray(pos, headEnd).toString("utf8")
+ const bodyStart = headEnd + sep.length
+ const next = buf.indexOf(delim, bodyStart)
+ if (next < 0) break
+ let bodyEnd = next
+ if (buf[bodyEnd - 2] === 0x0d && buf[bodyEnd - 1] === 0x0a) bodyEnd -= 2
+ parts.push({ headers, body: buf.subarray(bodyStart, bodyEnd) })
+ pos = next + delim.length
+ }
+ return parts
+}
+
+function fileNameOf(headers) {
+ const m = /filename\*?=(?:UTF-8''([^\r\n;]+)|"([^"]*)"|([^\r\n;]+))/i.exec(headers)
+ if (!m) return null
+ const raw = m[1] ? decodeURIComponent(m[1]) : (m[2] ?? m[3] ?? "")
+ return raw.trim()
+}
+
+function safeName(raw, sig, taken) {
+ const base = (raw.split(/[\\/]/).pop() || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "").slice(0, 120)
+ let name = base || "upload"
+ if (!sig.accepts.test(name)) name += `.${sig.ext}`
+ let out = name
+ let n = 1
+ while (taken.has(out)) {
+ const dot = name.lastIndexOf(".")
+ out = `${name.slice(0, dot)}-${n}${name.slice(dot)}`
+ n++
+ }
+ taken.add(out)
+ return out
+}
+
+function photoprismCopy(dir) {
+ const env = {
+ PHOTOPRISM_DATABASE_DRIVER: "mysql",
+ PHOTOPRISM_DATABASE_SERVER: `${DB.host}:${DB.port}`,
+ PHOTOPRISM_DATABASE_NAME: DB.database,
+ PHOTOPRISM_DATABASE_USER: DB.user,
+ PHOTOPRISM_DATABASE_PASSWORD: DB.password,
+ PHOTOPRISM_ORIGINALS_PATH: ORIGINALS_PATH,
+ PHOTOPRISM_STORAGE_PATH: STORAGE_PATH,
+ PHOTOPRISM_READONLY: "false",
+ PHOTOPRISM_DISABLE_TENSORFLOW: "true",
+ PHOTOPRISM_DISABLE_CLASSIFICATION: "true",
+ PHOTOPRISM_DISABLE_FACES: "true",
+ PHOTOPRISM_LOG_LEVEL: "info",
+ }
+ const assignments = Object.entries(env).map(([k, v]) => `${k}=${v}`)
+ const args = ["-n", "-u", PHOTOPRISM_USER, "env", ...assignments, PHOTOPRISM_BIN, "cp", dir]
+ return execFileAsync("sudo", args, { cwd: PHOTOPRISM_HOME, timeout: IMPORT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 })
+}
+
+async function handleImport(req, res) {
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" })
+
+ const ctype = req.headers["content-type"] || ""
+ const bm = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(ctype)
+ if (!/^multipart\/form-data/i.test(ctype) || !bm) {
+ return json(res, 400, { error: "expected multipart/form-data" })
+ }
+
+ let body
+ try {
+ body = await readBody(req, MAX_UPLOAD_BYTES)
+ } catch (err) {
+ if (err.code === "TOO_LARGE") return json(res, 413, { error: `upload exceeds ${MAX_UPLOAD_BYTES} bytes` })
+ return json(res, 400, { error: "could not read upload" })
+ }
+
+ const taken = new Set()
+ const files = []
+ for (const part of parseMultipart(body, (bm[1] || bm[2]).trim())) {
+ const raw = fileNameOf(part.headers)
+ if (!raw || part.body.length === 0) continue
+ const sig = sniff(part.body)
+ if (!sig) return json(res, 415, { error: `unsupported file type: ${raw}` })
+ if (files.length >= MAX_UPLOAD_FILES) return json(res, 413, { error: `too many files (max ${MAX_UPLOAD_FILES})` })
+ files.push({ name: safeName(raw, sig, taken), data: part.body })
+ }
+ if (files.length === 0) return json(res, 400, { error: "no image files in request" })
+
+ const batch = randomUUID()
+ const dir = join(STAGING_ROOT, batch)
+ try {
+ await mkdir(dir, { recursive: true })
+ for (const f of files) await writeFile(join(dir, f.name), f.data)
+ } catch (err) {
+ console.error("pathways: staging failed:", err.message)
+ return json(res, 500, { error: "could not stage upload" })
+ }
+
+ try {
+ const { stdout, stderr } = await photoprismCopy(dir)
+ console.log(`pathways: imported ${files.length} file(s) via batch ${batch}`)
+ if (stderr) console.error(`pathways: photoprism cp stderr: ${stderr.slice(-2000)}`)
+ else if (stdout) console.log(`pathways: photoprism cp: ${stdout.slice(-2000)}`)
+ } catch (err) {
+ const detail = (err.stderr || err.stdout || err.message || "").slice(-2000)
+ console.error(`pathways: import failed (batch ${batch} kept at ${dir}):`, detail)
+ return json(res, 500, { error: "photoprism import failed", batch, detail })
+ }
+
+ await refresh()
+ await rm(dir, { recursive: true, force: true }).catch(() => {})
+ return json(res, 200, { ok: true, imported: files.length, batch })
+}
+
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
const path = url.pathname
@@ -212,6 +384,7 @@ const server = http.createServer(async (req, res) => {
photos,
})
}
+ if (path === "/api/import") return await handleImport(req, res)
if (path === "/api/health") return json(res, 200, { ok: true })
if (path === "/" || path === "") {
@@ -230,6 +403,7 @@ const server = http.createServer(async (req, res) => {
server.listen(PORT, "127.0.0.1", () => {
console.log(`pathways listening on 127.0.0.1:${PORT}`)
+ mkdir(STAGING_ROOT, { recursive: true }).catch((err) => console.error("pathways: staging dir:", err.message))
refresh()
})
setInterval(refresh, 30 * 60 * 1000).unref()