const fs = require("fs"); const uuid = require("uuid"); const Koa = require("koa"); const Router = require("@koa/router"); const bodyParser = require("koa-bodyparser"); const cors = require("@koa/cors"); const multer = require("@koa/multer"); const mount = require("koa-mount"); const serve = require("koa-static"); const router = new Router(); const PUID = Number(process.env.PUID || '0'); const PGID = Number(process.env.PGID || '0'); let BASE_PATH = process.env.BASE_PATH || ''; if (BASE_PATH === "/") { BASE_PATH = ''; } const CONFIG_DIR = process.env.CONFIG_DIR || 'config'; const TASKS_DIR = process.env.TASKS_DIR || 'tasks'; const TITLE = process.env.TITLE || ''; const PORT = process.env.PORT || 8080; const multerInstance = multer(); const app = new Koa(); async function getTags(ctx) { const subPath = decodeURIComponent(ctx.request.url.substring("/tags".length)); const tags = await fs.promises .readFile(`${CONFIG_DIR}/tags.json`) .then((res) => JSON.parse(res.toString())) .catch((err) => ({})); const pathTags = JSON.stringify(tags[subPath]); ctx.body = pathTags || {}; ctx.status = 200; } router.get("/tags/:path*", getTags); async function updateTagBackgroundColor(ctx) { const subPath = decodeURIComponent(ctx.request.url.substring("/tags".length)); const tags = await fs.promises .readFile(`${CONFIG_DIR}/tags.json`) .then((res) => JSON.parse(res.toString() || "{}")) .catch((err) => ({})); const tagsColors = ctx.request.body; const newTags = { ...tags, [subPath]: tagsColors }; await fs.promises.writeFile( `${CONFIG_DIR}/tags.json`, JSON.stringify(newTags) ); ctx.status = 204; } router.patch("/tags/:path*", updateTagBackgroundColor); async function getTitle(ctx) { ctx.body = TITLE; } router.get("/title", getTitle); async function getResource(ctx) { const path = ctx.request.url.substring("/resources".length); const resources = await fs.promises.readdir( `${TASKS_DIR}/${decodeURIComponent(path)}`, { withFileTypes: true } ).catch(err => { console.log(err.code) if (err.code === 'ENOENT') { fs.promises.mkdir(`${TASKS_DIR}/${decodeURIComponent(path)}`) return []; } }) const lanes = resources .filter((dir) => dir.isDirectory() && !dir.name.startsWith(".")) .map((dir) => dir.name); const lanesWithFiles = await Promise.all( lanes.map(async (lane) => { const filesPromises = await fs.promises .readdir(`${TASKS_DIR}/${decodeURIComponent(`${path}`)}/${lane}`) .then((files) => files .filter( (fileName) => fileName.endsWith(".md") && !fileName.startsWith(".") ) .map(async (fileName) => { const getFileContent = fs.promises.readFile( `${TASKS_DIR}/${decodeURIComponent( `${path}` )}/${lane}/${fileName}` ); const getFileStats = fs.promises.stat( `${TASKS_DIR}/${decodeURIComponent( `${path}` )}/${lane}/${fileName}` ); const [content, stats] = await Promise.all([ getFileContent, getFileStats, ]); return { name: fileName.substring(0, fileName.length - 3), content: content.toString(), lastUpdated: stats.mtime, createdAt: stats.birthtime, }; }) ); const files = await Promise.all(filesPromises); return { name: lane, files, }; }) ); ctx.body = lanesWithFiles; } router.get("/resource/:path*", getResource); async function createResource(ctx) { const subPath = decodeURIComponent(ctx.request.url.substring("/resources".length)); const isFile = ctx.request.body?.isFile; const content = ctx.request.body?.content || ""; if (isFile) { await fs.promises.writeFile(`${TASKS_DIR}/${subPath}`, content); } else { await fs.promises.mkdir(`${TASKS_DIR}/${subPath}`); } if (PUID && PGID) { await fs.promises.chown(`${TASKS_DIR}/${subPath}`, PUID, PGID); } ctx.status = 201; } router.post("/resource/:path*", createResource); async function updateResource(ctx) { const oldPath = decodeURIComponent(ctx.request.url.substring("/resources".length)); const newPath = decodeURIComponent(ctx.request.body.newPath || oldPath).replaceAll( /<>:"\/\\\|\?\*#/g, " " ); if (newPath !== oldPath) { await fs.promises.rename( `${TASKS_DIR}/${oldPath}`, `${TASKS_DIR}/${newPath}` ); } const newContent = ctx.request.body.content; const isFile = fs.lstatSync(`${TASKS_DIR}/${newPath}`).isFile(); if (isFile && newContent !== undefined) { await fs.promises.writeFile( `${TASKS_DIR}/${newPath}`, newContent ); } if (PUID && PGID) { await fs.promises.chown(`${TASKS_DIR}/${newPath}`, PUID, PGID); } ctx.status = 204; } router.patch("/resource/:path*", updateResource); async function deleteResource(ctx) { const subPath = decodeURIComponent(ctx.request.url.substring("/resources".length)); await fs.promises.rm(`${TASKS_DIR}/${subPath}`, { force: true, recursive: true, }); ctx.status = 204; } router.delete("/resource/:path*", deleteResource); async function uploadImage(ctx) { await fs.promises.mkdir(`${CONFIG_DIR}/images`, { recursive: true, }); const originalname = ctx.request.file.originalname; const extension = originalname.split(".").at(-1); const imageName = `${uuid.v4()}.${extension}`; await fs.promises.writeFile( `${CONFIG_DIR}/images/${imageName}`, ctx.request.file.buffer ); if (PUID && PGID) { await fs.promises.chown( `${CONFIG_DIR}/images/${imageName}`, PUID, PGID ); } ctx.status = 200; ctx.body = imageName; } router.post("/image", multerInstance.single("file"), uploadImage); async function updateSort(ctx) { const subPath = decodeURIComponent(ctx.request.url.substring("/sort".length)); const newSort = { [subPath]: ctx.request.body || {} }; const currentSort = await fs.promises .readFile(`${CONFIG_DIR}/sort.json`) .then((res) => JSON.parse(res.toString())) .catch((err) => []); const mergedSort = JSON.stringify({ ...(currentSort || {}), ...newSort }); await fs.promises.writeFile( `${CONFIG_DIR}/sort.json`, mergedSort ); if (PUID && PGID) { await fs.promises.chown(`${CONFIG_DIR}/sort.json`, PUID, PGID); } ctx.status = 200; } router.put("/sort/:path*", updateSort); async function getSort(ctx) { const subPath = decodeURIComponent(ctx.request.url.substring("/sort".length)); const sort = await fs.promises .readFile(`${CONFIG_DIR}/sort.json`) .then((res) => JSON.parse(res.toString())) .catch((err) => []); const pathSort = JSON.stringify(sort[subPath]); ctx.body = pathSort || {}; ctx.status = 200; } router.get("/sort/:path*", getSort); app.use(cors()); app.use(bodyParser()); const httpInstance = new Koa(); httpInstance.use(async (ctx, next) => { try { await next(); } catch (err) { console.error(err); err.status = err.statusCode || err.status || 500; throw err; } }); app.use( mount(`${BASE_PATH}/_api`, httpInstance) ); app.use( mount(`${BASE_PATH}/_api/image`, serve(`${CONFIG_DIR}/images`)) ); app.use(mount(`${BASE_PATH}/_api`, router.routes())); app.use(mount((`${BASE_PATH || '/'}`), (ctx, next) => { let middleware = serve('static') if (/stylesheets\/(.*).css$/.test(ctx.request.url)) { const fileName = ctx.request.url.split('/stylesheets').at(-1); ctx.request.url = fileName; middleware = serve(`${CONFIG_DIR}/stylesheets`) } else if (ctx.request.url.endsWith('apple-touch-icon.png')) { ctx.request.url = `/favicon/apple-touch-icon.png` } else if (/favicon\/favicon-(16|32)x(16|32).png$/.test(ctx.request.url)) { const dimension = /favicon\/favicon-(16|32)x(16|32).png$/.exec(ctx.request.url)[1] ctx.request.url = `/favicon/favicon-${dimension}x${dimension}.png` } else if (/assets\/(.*).(js|css)$/.test(ctx.request.url)) { const fileName = ctx.request.url.split('/').at(-1); ctx.request.url = `/assets/${fileName}` } // default to index.html else { ctx.request.url = '/' } return middleware(ctx, next) })); async function getfilesPaths(dirPath) { const dirsAndFiles = (await fs.promises.readdir(dirPath, { withFileTypes: true } )); const files = dirsAndFiles .filter(dirOrFile => dirOrFile.isFile() && dirOrFile.name.endsWith(".md") && !dirOrFile.name.startsWith(".") ).map(file => `${dirPath}/${file.name}`) const subFilesPromises = dirsAndFiles .filter(dirOrFile => dirOrFile.isDirectory()) .map(dir => getfilesPaths(`${dirPath}/${dir.name}`)) const subFiles = await Promise.all(subFilesPromises).then(res => res.flat()) return [...files, ...subFiles]; } async function removeUnusedImages() { await fs.promises.mkdir(TASKS_DIR, { recursive: true }); const tasksDir = TASKS_DIR; const allFiles = await getfilesPaths(tasksDir) const filesReadPromises = allFiles.map(file => fs.promises.readFile(file)) const filesContents = await Promise.all(filesReadPromises).then(buffers => buffers.map(buffer => buffer.toString())); const imagesBeingUsed = filesContents .map((content) => content.match(/!\[[^\]]*\]\(([^\s]+[.]*)\)/g)) .flat() .filter((image) => !!image && image.includes("_api/image/")) .map((image) => image.split("_api/image/")[1].slice(0, -1)); const allImages = await fs.promises.readdir( `${CONFIG_DIR}/images` ); const unusedImages = allImages.filter( (image) => !imagesBeingUsed.includes(image) ); await Promise.all( unusedImages.map((image) => fs.promises.rm(`${CONFIG_DIR}/images/${image}`) ) ); } const localImagesCleanupInterval = /^\d{1,}$/.test(process.env.LOCAL_IMAGES_CLEANUP_INTERVAL) ? Number(process.env.LOCAL_IMAGES_CLEANUP_INTERVAL) : 1440 if (localImagesCleanupInterval) { const intervalInMs = localImagesCleanupInterval * 60000; try { if (intervalInMs > 0) { setInterval(removeUnusedImages, intervalInMs); } } catch (error) { console.error(error); } } console.log('API starting at ' + PORT) fs.promises.mkdir(CONFIG_DIR, { recursive: true }); if (PUID && PGID) { fs.promises.chown(CONFIG_DIR, PUID, PGID); } app.listen(PORT);