import express from 'express'; import fs from 'fs/promises'; import fsSync from 'fs'; import { randomUUID } from 'crypto'; import { createClient } from 'webdav'; import { pathKey, fanoutPath, ensureDir, getRow, upsertRow, CACHE_DIR } from './cache.ts'; import { checkIfHasPerms, getDirectoryFileCount, statOne, toRegExp } from './helpers.ts'; import path from 'path'; type statRetType = { "filename": string, "basename": string, "lastmod": string, "size": number, "type": "file" | "directory", "etag": string, "mime"?: string }; type ReqFileType = { filename: string; // Name of the form field associated with this file. originalname?: string; // Name of the file on the uploader's computer. mimetype: string; // Value of the Content-Type header for this file. size?: number; // Size of the file in bytes. buffer: Buffer; // A Buffer containing the file content. localFilename: string; // the temporary ID assigned to the file }; declare global { namespace Express { interface Request { fobj?: { fpath: any, deep?: boolean, bypasscache?: boolean, startInd?: number, limit?: number }, file: ReqFileType } } } const { NEXTCLOUD_APP_ID: APP_ID, NEXTCLOUD_APP_PASS: APP_PASS, NEXTCLOUD_WEBDAV_ADDR: ADDR, NEXTCLOUD_ACCESS_DIRS: _DIRS, PORT: PORT_RAW } = process.env, PORT = PORT_RAW || 1111; if (!(ADDR && APP_ID && APP_PASS)) { throw new Error("VARIABLES NOT FOUND IN ENV"); } // const ADDR_URL = new URL(ADDR); const app = express(); app.use(express.json()); app.use(express.text()); app.use((await import('cors')).default()); app.use((req, res, next) => { if (req.path === '/openapi.json') { return res.sendFile('openapi.json', { root: '.' }); } // GET/PUT has independant handlers else if (['GET', 'PUT'].includes(req.method)) return next(); const pth = checkIfHasPerms(req); if (!pth && req.body?.fpath) { return res.status(401).send(`Not allowed to access "${req.body.fpath}"`); } else if (!pth) { return res.status(404).send("Unknown path"); } else if (typeof pth === 'string') { console.warn(`somehow got string out of checkIfHasPerms:\npth: ${pth}\nreq.body: ${req.body}`); return res.sendStatus(400); } req.fobj = pth; next(); }); export const DIRS: string[] = (() => { try { if (!_DIRS) return null; return JSON.parse(_DIRS).map(toRegExp) } catch (err) { console.error(err); } return null; })(); if (!DIRS) { console.warn("NEXTCLOUD_ACCESS_DIRS not specified, tool has full access to all files"); } const client = createClient(process.env.NEXTCLOUD_WEBDAV_ADDR!, { username: process.env.NEXTCLOUD_APP_ID!, password: process.env.NEXTCLOUD_APP_PASS! }); app.post('/file', async (req, res) => { if (!req.fobj) return res.sendStatus(401); const { fpath, bypasscache } = req.fobj; try { // read current etag/size from webdav const { etag, size, mime } = await statOne(fpath, client).catch((err) => { if (typeof err === 'string') { console.error(err); if (!res.headersSent) res.sendStatus(404).send(err); return { etag: undefined, size: -1, mime: -1 }; } throw err; }); if (!etag) return; // lookup mapping const key = pathKey(process.env.NEXTCLOUD_WEBDAV_ADDR!, fpath), cache_path = fanoutPath(key), row = bypasscache ? null : getRow(key); // cached file if (row && row.etag === etag) { const data = await fs.readFile(row.cache_path); res.setHeader('content-type', row.mime ?? 'application/octet-stream'); return res.send(data); } // TODO: If-None-Match here if client lib exposes raw headers const buf = await client.getFileContents(fpath).catch(console.error); if (!buf) return res.sendStatus(500); await ensureDir(cache_path); await fs.writeFile(cache_path, buf as any); upsertRow({ path_key: key, fpath, etag, size, mime: mime ?? null, cache_path, updated_at: Date.now() }); res.setHeader('content-type', mime ?? 'application/octet-stream'); res.send(buf); } catch (err) { console.error(err); if (!res.headersSent) res.sendStatus(500); } }); app.post('/dir', async (req, res) => { try { if (!req.fobj) return res.sendStatus(401); const { fpath, deep } = req.fobj, { startInd = 0, limit = 250 } = req.body; if (!fpath) return res.sendStatus(400); if (!(await client.exists(fpath))) { return res.status(404).send("Directory not found"); } const getFilesInDir = async () => { for (let i = 0; i < 3; i++) { try { return await client.getDirectoryContents(fpath, { deep }); } catch (err: any) { if (err.response?.status === 500) { await new Promise(resolve => setTimeout(resolve, 2000)); continue; } console.error(`error for "${fpath}":`); throw err; } } const counts = await getDirectoryFileCount('/Photos', client); let err = "failed to find dir contents (perhaps the server returned 500 too many times?)"; err += `\n"${fpath}" contains the following, consider listing directories individually (instead of using 'deep' if too many are present: ${JSON.stringify(counts)}`; res.status(500).send(err); } const ret = await getFilesInDir(); if (!ret) return; const filesInDir = Array.isArray(ret) ? ret : ret.data; res.json(filesInDir.slice(startInd, limit)); } catch (err) { console.error(err); res.sendStatus(500); } }); // openwebui doesn't really help with `multerInstance.single('file'),` const ensureMockFile = async (req: express.Request, res: express.Response, next: express.NextFunction) => { try { const { file, fdir } = req.body as { fdir: any, file: { filename: any, buffer: any, mimetype: any } }; if (!file) return res.status(400).send("missing file"); if (typeof fdir !== 'string') { return res.status(400).send("Missing fdir in request"); } if (typeof file.filename !== 'string') { return res.status(400).send("Invalid filename"); } if (typeof file.buffer === 'undefined') { return res.status(400).send("Invalid file upload payload"); } if (!checkIfHasPerms(fdir)) return res.sendStatus(401); const fname = `${randomUUID().replaceAll('-', '')}_${file.filename}`, fpath = path.join(CACHE_DIR, fname); await fs.writeFile(fpath, file.buffer).catch((err) => { console.error(err); res.sendStatus(500); }); if (res.headersSent) return; req.file = { ...file, localFilename: fname, mimetype: req.body.mimetype || '' }; next(); } catch (err) { console.error(err); if (!res.headersSent) res.sendStatus(500); } }; app.put('/file', ensureMockFile, async (req, res) => { const file = req.file; if (!file) return; const { fdir, createnewdirs } = req.body as { fdir: string, createnewdirs: boolean }; if (!fdir) return (res.headersSent || res.status(400).send("missing fdir in body")); if (createnewdirs && !(await client.exists(fdir))) { await client.createDirectory(fdir, { recursive: true }); } const fname = file.filename || file.originalname, fpath = path.join(fdir, fname!); if (await client.exists(fpath)) { return res.status(503).send('file already exists, you do not have permissions to overwrite files'); } const sstr = fsSync.createReadStream(path.join(CACHE_DIR, file.localFilename)).pipe(client.createWriteStream(fpath, { overwrite: false }, (r) => { if (res.headersSent) return; res.send(`file uploaded successfully to ${r.url}`); })); sstr.on('error', (err) => { console.error(err); if (res.headersSent) return; res.status(500).send("failed to upload file"); }); }); app.get('/ping', (_, res) => res.sendStatus(200)); app.listen(PORT, (err) => { if (err) throw err; console.log(`app listening on port ${PORT}`); });