mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
fix: touch only a model's own pictures, check picture files once, drop plate folders for other files
This commit is contained in:
1 parent
0d505b716a
commit
ecc551ef27
8 files changed
+130
-46
No files matched your search
+16
-12
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!))) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in new issue
Block a user