Files
ollama-plus/nextcloud/server.ts
T
2025-10-11 11:29:11 -04:00

293 lines
7.5 KiB
TypeScript

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}`);
});