All files / src/storage local-directory-gpx-store.ts

48% Statements 36/75
62.5% Branches 10/16
50% Functions 3/6
48% Lines 36/75

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 871x         3x 3x 3x 1x 1x 2x 2x 3x 3x     2x 2x   1x 1x   1x 3x 3x 3x 1x 3x     3x 1x     1x 1x 1x 1x       1x 1x 1x 1x 1x 3x   1x                         1x                 1x                             1x  
import { access, constants, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { AppError } from "@ontrack/backend-common";
import type { GpxContent, GpxContentStore } from "./gpx-content-store.js";
 
function resolveSafePath(rootAbsolute: string, fileName: string): string {
  const base = path.basename(fileName.trim());
  if (base.length === 0 || base !== fileName.trim()) {
    throw new AppError(400, "INVALID_FILE_NAME", "File name must be a single path segment");
  }
  const root = path.resolve(rootAbsolute);
  const full = path.resolve(root, base);
  const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
  if (full !== root && !full.startsWith(rootWithSep)) {
    throw new AppError(400, "INVALID_FILE_NAME", "Path escapes content root");
  }
  return full;
}
 
export class LocalDirectoryGpxStore implements GpxContentStore {
  constructor(private readonly rootAbsolute: string) {}
 
  async getByFileName(fileName: string): Promise<GpxContent> {
    const fullPath = resolveSafePath(this.rootAbsolute, fileName);
    try {
      await access(fullPath, constants.R_OK);
      const st = await stat(fullPath);
      if (!st.isFile()) {
        throw new AppError(404, "TRACK_FILE_NOT_FOUND", "GPX object is not a file");
      }
    } catch (err) {
      if (err instanceof AppError) {
        throw err;
      }
      const code = (err as NodeJS.ErrnoException).code;
      if (code === "ENOENT" || code === "ENOTDIR") {
        throw new AppError(404, "TRACK_FILE_NOT_FOUND", "GPX file not found in storage");
      }
      throw err;
    }
 
    const body = await readFile(fullPath);
    return {
      body,
      contentType: "application/gpx+xml; charset=utf-8",
    };
  }
 
  async saveNew(fileName: string, body: Buffer): Promise<void> {
    const fullPath = resolveSafePath(this.rootAbsolute, fileName);
    try {
      await writeFile(fullPath, body, { flag: "wx", mode: 0o644 });
    } catch (err) {
      const code = (err as NodeJS.ErrnoException).code;
      if (code === "EEXIST") {
        throw new AppError(409, "FILE_NAME_TAKEN", "GPX file already exists in storage");
      }
      throw err;
    }
  }
 
  async deleteByFileName(fileName: string): Promise<void> {
    const fullPath = resolveSafePath(this.rootAbsolute, fileName);
    await unlink(fullPath).catch((err: NodeJS.ErrnoException) => {
      if (err.code !== "ENOENT") {
        throw err;
      }
    });
  }
 
  async archive(fileName: string): Promise<void> {
    const fullPath = resolveSafePath(this.rootAbsolute, fileName);
    const trashDir = path.join(this.rootAbsolute, ".trash");
    const dest = path.join(trashDir, path.basename(fullPath));
    try {
      await mkdir(trashDir, { recursive: true });
      await rename(fullPath, dest);
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code === "ENOENT") {
        // Исходника нет — перенос считаем выполненным (идемпотентность).
        return;
      }
      throw err;
    }
  }
}