fix: touch only a model's own pictures, check picture files once, drop plate folders for other files

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-20 15:46:22 +02:00
1 parent 0d505b716a
commit ecc551ef27
8 files changed
+130 -46

No files matched your search

+16 -12
View File
@@ -15,8 +15,8 @@ import type {
User,
} from "@prisma/client";
import type { AssetHome } from "./services/modelAssets";
import { plateThumbExists, plateThumbPath } from "./services/printService";
import { previewImageExists, previewImagePath } from "./services/previewImageService";
import { plateThumbPath } from "./services/printService";
import { previewImagePath } from "./services/previewImageService";
import { preparedFilename } from "./services/preparedPrint";
import {
canRenderModelImage,
@@ -202,11 +202,19 @@ export type CategoryOut = {
cults3d_cat_ids: string;
};
/** `url` versioned by the file's mtime; null when it isn't there. One stat, as a storage move can take the file
* away between an existence check and a second look. */
function versionedUrl(file: string, url: string): string | null {
try {
return `${url}?v=${fs.statSync(file).mtimeMs}`;
} catch {
return null;
}
}
/** `home`, here and below, is the model's Print.assetsPath. */
export function plateThumbUrl(plateId: string, home: AssetHome): string | null {
if (!plateThumbExists(plateId, home)) return null;
const mtime = fs.statSync(plateThumbPath(plateId, home)).mtimeMs;
return `/plate/${plateId}/thumb.jpg?v=${mtime}`;
return versionedUrl(plateThumbPath(plateId, home), `/plate/${plateId}/thumb.jpg`);
}
function previewGlbUrl(plate: Plate): string | null {
@@ -221,10 +229,8 @@ function previewGlbUrl(plate: Plate): string | null {
/** The plate's grey render for the browser to tint; unversioned until it exists, as asking for it queues it. */
export function renderUrl(plate: { id: string; filename: string }, home: AssetHome): string | null {
if (!canRenderModelImage(plate.filename)) return null;
const file = modelImagePath(plate.id, home, "render");
return fs.existsSync(file)
? `/plate/${plate.id}/render.png?v=${fs.statSync(file).mtimeMs}`
: `/plate/${plate.id}/render.png`;
const url = `/plate/${plate.id}/render.png`;
return versionedUrl(modelImagePath(plate.id, home, "render"), url) ?? url;
}
export function toPlateOut(printId: string, plate: Plate, home: AssetHome): PlateOut {
@@ -258,9 +264,7 @@ export function printCover(
}
function previewImageUrl(id: string, home: AssetHome): string | null {
if (!previewImageExists(id, home)) return null;
const mtime = fs.statSync(previewImagePath(id, home)).mtimeMs;
return `/preview-image/${id}/file.jpg?v=${mtime}`;
return versionedUrl(previewImagePath(id, home), `/preview-image/${id}/file.jpg`);
}
export function toPreviewImageOut(image: PreviewImage, home: AssetHome): PreviewImageOut | null {
+3 -1
View File
@@ -15,6 +15,7 @@ import {
relocatePrint,
relocatePrintsForToken,
removeAssetHome,
assetOwnerIds,
removePlatePictures,
uniqueModelName,
} from "../services/printService";
@@ -657,6 +658,7 @@ router.post(
async function deletePrint(userId: string, printId: string): Promise<void> {
const full = await loadFullPrint(userId, printId);
const assetOwners = await assetOwnerIds(printId);
await deleteAllPrintFiles(printId);
await deleteAllPreviewImages(printId);
await deleteAllDescriptionImages(printId);
@@ -665,7 +667,7 @@ async function deletePrint(userId: string, printId: string): Promise<void> {
await deletePlateFiles(plate);
await removePlatePictures(plate.id, full.print.assetsPath);
}
await removeAssetHome(full.print.assetsPath);
await removeAssetHome(full.print.assetsPath, assetOwners);
void recordActivity(userId, "delete", printId, full.print.name);
void createLog({
userId,
+3 -2
View File
@@ -17,7 +17,7 @@ import { deleteAllPrintFiles } from "./printFileService";
import { deleteAllPreviewImages } from "./previewImageService";
import { deleteAllDescriptionImages } from "./descriptionImageService";
import { deletePlateFiles } from "./printCreation";
import { removeAssetHome, removePlatePictures } from "./printService";
import { assetOwnerIds, removeAssetHome, removePlatePictures } from "./printService";
import { loadFullPrint } from "./printLoader";
import { removeUserTagMosaics } from "./tagMosaicService";
@@ -116,6 +116,7 @@ export async function deleteAllPrintsForUser(userId: string): Promise<number> {
const prints = await prisma.print.findMany({ where: { userId }, select: { id: true } });
for (const { id } of prints) {
const full = await loadFullPrint(userId, id);
const assetOwners = await assetOwnerIds(id);
await deleteAllPrintFiles(id);
await deleteAllPreviewImages(id);
await deleteAllDescriptionImages(id);
@@ -124,7 +125,7 @@ export async function deleteAllPrintsForUser(userId: string): Promise<number> {
await deletePlateFiles(plate);
await removePlatePictures(plate.id, full.print.assetsPath);
}
await removeAssetHome(full.print.assetsPath);
await removeAssetHome(full.print.assetsPath, assetOwners);
}
// With no models left the user has no tags, so none of their tag pictures is needed.
await removeUserTagMosaics(userId);
+14
View File
@@ -116,6 +116,20 @@ export async function adoptLegacyAsset(home: string, file: AssetFile): Promise<b
return true;
}
/** The files in a model's pictures folder that are its own -- named by one of `ids`, its plates' and images' --
* relative to it. Anything else is left alone: another model's files, when a category puts them in here. */
export async function ownedAssetFiles(home: string, ids: Iterable<string>): Promise<string[]> {
const owners = new Set(ids);
const found: string[] = [];
for (const folder of new Set(Object.values(ASSET_SUBFOLDERS))) {
const entries = await fs.readdir(path.join(STORAGE, home, folder), { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.isFile() && owners.has(entry.name.split(".")[0])) found.push(path.join(folder, entry.name));
}
}
return found;
}
/** Every file under a model's pictures folder, relative to it with "/" separators. */
export async function listAssetFiles(home: string): Promise<{ path: string; size: number }[]> {
const root = path.join(STORAGE, home);
+3 -1
View File
@@ -14,6 +14,7 @@ import {
managedPlatePath,
pruneEmptyStorageDirs,
removeAssetHome,
assetOwnerIds,
removePlatePictures,
renderPlateStoragePath,
saveThumbFromFile,
@@ -244,6 +245,7 @@ async function discardPrint(printId: string): Promise<void> {
try {
const plates = await prisma.plate.findMany({ where: { printId } });
const home = await printAssetHome(printId);
const assetOwners = await assetOwnerIds(printId);
await prisma.print.delete({ where: { id: printId } });
for (const plate of plates) {
await removePlatePictures(plate.id, home);
@@ -252,7 +254,7 @@ async function discardPrint(printId: string): Promise<void> {
await fs.rm(managed, { force: true });
await pruneEmptyStorageDirs(path.dirname(managed));
}
await removeAssetHome(home);
await removeAssetHome(home, assetOwners);
} catch (err) {
console.error("Failed to discard half-created print", printId, err);
}
+55 -29
View File
@@ -8,13 +8,14 @@ import { prisma } from "../db";
import { listZipEntries, readZipEntry } from "../utils/zipReader";
import {
adoptLegacyAsset,
ASSET_SUBFOLDERS,
ASSETS_FOLDER,
assetExists,
assetPath,
assetTarget,
descriptionImageFile,
galleryImageFile,
listAssetFiles,
ownedAssetFiles,
plateAssetHome,
plateRenderFile,
plateThumbFile,
@@ -189,14 +190,17 @@ async function printTemplateValues(print: PrintLike, template: string): Promise<
}
/** {plate} with the separator next to it taken out, for the files that aren't plates: "{model}-{plate}-{filename}"
* gives "{model}-{filename}". */
* gives "{model}-{filename}". A folder per plate ("Plate {plate}/") goes whole, so they sit beside the plates'
* folders. */
function withoutPlateToken(template: string): string {
return template
.split("/")
.map((segment) =>
segment.replace(/([ _.-]*)\{plate\}([ _.-]*)/g, (match, before: string, after: string, offset: number) =>
offset === 0 || offset + match.length === segment.length ? "" : before || after,
),
const segments = template.split("/");
return segments
.map((segment, index) =>
index < segments.length - 1 && segment.includes("{plate}")
? ""
: segment.replace(/([ _.-]*)\{plate\}([ _.-]*)/g, (match, before: string, after: string, offset: number) =>
offset === 0 || offset + match.length === segment.length ? "" : before || after,
),
)
.join("/");
}
@@ -419,26 +423,33 @@ export async function pruneEmptyStorageDirs(start: string): Promise<void> {
}
}
/** Moves one file or folder of a model; `save` records the new place, and the file goes back if that fails. */
/** Moves one file of a model; `save` records the new place, and the file goes back if that fails. */
async function moveStored(oldPath: string, newPath: string, save: () => Promise<unknown>): Promise<boolean> {
const oldExists = fsSync.existsSync(oldPath);
if (oldExists) {
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.rename(oldPath, newPath);
}
return (await moveAllStored([[oldPath, newPath]], save)) > 0;
}
/** Moves files that one record finds, as moveStored; returns how many were there to move. */
async function moveAllStored(moves: [string, string][], save: () => Promise<unknown>): Promise<number> {
const present = moves.filter(([oldPath]) => fsSync.existsSync(oldPath));
const done: [string, string][] = [];
try {
for (const [oldPath, newPath] of present) {
await fs.mkdir(path.dirname(newPath), { recursive: true });
await fs.rename(oldPath, newPath);
done.push([oldPath, newPath]);
}
await save();
} catch (err) {
// Put it back, so the stored path still finds it.
if (oldExists) {
// Put them back, so the stored path still finds them.
for (const [oldPath, newPath] of done.toReversed()) {
await fs.mkdir(path.dirname(oldPath), { recursive: true });
await fs.rename(newPath, oldPath);
await pruneEmptyStorageDirs(path.dirname(newPath));
}
throw err;
}
if (oldExists) await pruneEmptyStorageDirs(path.dirname(oldPath));
return oldExists;
for (const [oldPath] of present) await pruneEmptyStorageDirs(path.dirname(oldPath));
return present.length;
}
const samePath = (relative: string, other: string) => path.resolve(STORAGE, relative) === path.resolve(STORAGE, other);
@@ -477,8 +488,18 @@ async function modelAssetFiles(printId: string, plateIds: string[]): Promise<Ass
];
}
/** The model's pictures folder, moved whole to where the template now puts it, with any pictures still in the old
* shared folders brought in. */
/** The ids a model's pictures are named by: its plates' and its gallery and description images'. */
export async function assetOwnerIds(printId: string): Promise<string[]> {
const [plates, images, descriptions] = await Promise.all([
prisma.plate.findMany({ where: { printId }, select: { id: true } }),
prisma.previewImage.findMany({ where: { printId }, select: { id: true } }),
prisma.descriptionImage.findMany({ where: { printId }, select: { id: true } }),
]);
return [...plates, ...images, ...descriptions].map((row) => row.id);
}
/** The model's pictures moved to where the template now puts its folder, with any still in the old shared folders
* brought in. Only its own files go: another model's can be in there too. */
async function relocateAssets(
print: PrintLike & { assetsPath: string | null },
template: string,
@@ -494,12 +515,11 @@ async function relocateAssets(
// Saved before anything moves, so a picture moved below is found even if the move stops part-way.
await prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } });
} else if (!samePath(current, home)) {
const from = path.join(STORAGE, current);
const count = (await listAssetFiles(current)).length;
await moveStored(from, path.join(STORAGE, home), () =>
prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } }),
const owned = await ownedAssetFiles(current, await assetOwnerIds(print.id));
moved += await moveAllStored(
owned.map((file) => [path.join(STORAGE, current, file), path.join(STORAGE, home, file)]),
() => prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } }),
);
moved += count;
} else if (current !== home) {
await prisma.print.update({ where: { id: print.id }, data: { assetsPath: home } });
}
@@ -780,10 +800,16 @@ export async function plateThumbReplaceable(plateId: string): Promise<boolean> {
return plate?.thumbGenerated ?? false;
}
/** The whole pictures folder, once its model is gone. */
export async function removeAssetHome(home: AssetHome): Promise<void> {
/** What's left of the model's pictures once it's gone, and the folder with them. `ids` are its assetOwnerIds,
* taken before its rows went; only files named by them go, as another model's can be in the folder too. */
export async function removeAssetHome(home: AssetHome, ids: string[]): Promise<void> {
if (!home) return;
const abs = path.join(STORAGE, home);
await fs.rm(abs, { recursive: true, force: true }).catch(() => undefined);
await pruneEmptyStorageDirs(path.dirname(abs));
for (const file of await ownedAssetFiles(home, ids)) {
await fs.rm(path.join(abs, file), { force: true }).catch(() => undefined);
}
for (const folder of Object.values(ASSET_SUBFOLDERS).filter(Boolean)) {
await pruneEmptyStorageDirs(path.join(abs, folder));
}
await pruneEmptyStorageDirs(abs);
}
@@ -124,6 +124,8 @@ async function entriesUnder(folder: string): Promise<{ entries: Entry[]; picture
location: (): StorageFileLocation => (exists(file.storagePath) ? "managed" : "missing"),
})),
];
// Another model's files can be in a pictures folder, when a category puts them there; they're listed as its own.
const listed = new Set(entries.map((entry) => entry.path));
const pictures = await mapWithConcurrency(homes, PICTURE_FOLDER_READS, async (print) => {
const home = toSlash(print.assetsPath!);
return (await listAssetFiles(print.assetsPath!)).map((file) => ({
@@ -135,7 +137,11 @@ async function entriesUnder(folder: string): Promise<{ entries: Entry[]; picture
location: (): StorageFileLocation => "managed",
}));
});
entries.push(...pictures.flat());
for (const picture of pictures.flat()) {
if (listed.has(picture.path)) continue;
listed.add(picture.path);
entries.push(picture);
}
return { entries, pictureFolders: new Set(homes.map((print) => toSlash(print.assetsPath!))) };
}
+29
View File
@@ -139,6 +139,35 @@ describe("a model's pictures and supporting files", () => {
});
});
describe("another model's files inside a model's pictures folder", () => {
it("stay put when the model moves or is deleted", async () => {
// A category named like "<model>/Images" puts another model's files there.
const printId = await upload(`host-${stamp}.stl`, "solid host endsolid");
const home = (await prisma.print.findUniqueOrThrow({ where: { id: printId } })).assetsPath!;
const added = await request(app)
.post(`/api/print/${printId}/preview-images`)
.set(auth())
.attach("files", await png(90), "photo.png");
expect(added.status).toBe(200);
const foreign = ["Guest/guest.stl", "guest.jpg"];
for (const file of foreign) {
fs.mkdirSync(path.dirname(stored(home, file)), { recursive: true });
fs.writeFileSync(stored(home, file), "guest");
}
await reorganizeManagedPrints("{category-or-folder}/{model}/{id}/{filename}", userId);
const moved = (await prisma.print.findUniqueOrThrow({ where: { id: printId } })).assetsPath!;
expect(moved).not.toBe(home);
expect(fs.readdirSync(stored(moved))).not.toContain("guest.jpg");
for (const file of foreign) expect(fs.readFileSync(stored(home, file), "utf8")).toBe("guest");
await reorganizeManagedPrints("{category-or-folder}/{model}/{filename}", userId);
expect(await request(app).delete(`/api/print/${printId}`).set(auth())).toHaveProperty("status", 200);
for (const file of foreign) expect(fs.readFileSync(stored(home, file), "utf8")).toBe("guest");
fs.rmSync(stored(path.dirname(home)), { recursive: true, force: true });
});
});
describe("a model saved before pictures moved in beside its files", () => {
it("keeps its pictures and supporting files where they were until the move brings them over", async () => {
const name = `legacy-${stamp}`;