Ctrl K
ComunidadGuías…

backend/server.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);