import {db} from "./db/index.js"; import {Album} from "../models/portfolio/album.js"; import {ApiError, BadRequestError, NotFoundError, ValidationError} from "../models/errors/index.js"; import {jsonPatch} from "../utils/json-patch.js"; import {Photo} from "../models/portfolio/photo.js"; import {minio} from "./minio.js"; import cleanForId from "../utils/clean-for-id.js"; class AlbumsService { async getAlbums(instanceId) { return await db.albums.find({instanceId}, {sort: {"sortOrder": 1}}).toArray() } async createAlbum(instanceId, album) { let newAlbum = new Album({ ...album, instanceId, }); const latestAlbum = await db.albums.findOne({instanceId}, {limit: 1, sort: {"sortOrder": -1}}); newAlbum.albumId = cleanForId(newAlbum.name); newAlbum.sortOrder = latestAlbum?.sortOrder + 1 || 1; newAlbum.sort = "auto"; try { const result = await db.albums.insertOne(newAlbum, { checkKeys: true, bypassDocumentValidation: false }); if (result.acknowledged && result.insertedId) { return await db.albums.findOne({instanceId, albumId: newAlbum.albumId}); } throw new BadRequestError("The album could not be created.") } catch (err) { if (err instanceof BadRequestError) throw err; console.error(err); throw new BadRequestError(err.message); } } async updateAlbum({instanceId, albumId, name, description, sort}) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } album.description = description; album.sort = sort; if (album.name !== name) { album.name = name; album.albumId = cleanForId(album.name); } try { await db.albums.update(album); console.log("Album updated", album) return album; } catch(err) { if(err.message?.toLowerCase().indexOf("duplicate key") >= 0) { throw new BadRequestError("Duplicate name"); } throw new ApiError(err); } } async patchAlbum(instanceId, albumId, patchOperations) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } const patchPaths = patchOperations.map(op => op.path); const allowedPaths = ["/name", "/sort", "/description"]; const notAllowedPatches = patchPaths.filter(path => allowedPaths.indexOf(path) < 0) if(notAllowedPatches.length > 0) { throw new BadRequestError(`The following paths are invalid: ${notAllowedPatches.map(p => p.path).join(", ")}`) } const patchedAlbum = new Album(jsonPatch.applyPatch(album, patchOperations)); if(patchPaths.indexOf("/sort") >= 0) { if (!["manual", "auto"].includes(patchedAlbum.sort)) { throw new ValidationError([{ field: "sort", message: `Parameter 'sort' can only be 'manual' or 'auto', not ${patchedAlbum.sort}.` }]); } } if(patchPaths.indexOf("/name") >= 0) { patchedAlbum.albumId = cleanForId(patchedAlbum.name); } try { await db.albums.update(patchedAlbum); console.log("Album updated", patchedAlbum) return patchedAlbum; } catch(err) { if(err.message?.toLowerCase().indexOf("duplicate key") >= 0) { throw new BadRequestError("Duplicate name"); } throw new ApiError(err); } } async deleteAlbum(instanceId, albumId) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } await db.albums.deleteOne({instanceId, albumId}); } /** * @param {string} instanceId * @param {string} albumId * @param {"up"|"down"}direction * @return {Promise} */ async moveAlbum(instanceId, albumId, direction) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } const initialSortOrder = album.sortOrder; let albumToMove; albumToMove = await db.albums.findOne({instanceId, sortOrder: initialSortOrder + (direction === "up" ? -1 : 1)}); if (!albumToMove) { throw new BadRequestError(`Couldn't move album ${direction}, album's sortOrder is ${album.sortOrder}`); } album.sortOrder = albumToMove.sortOrder; albumToMove.sortOrder = initialSortOrder; await db.albums.update([ album, albumToMove ]); return await this.getAlbums(); } /** * @typedef FileUploadInfo * @property {string} fieldname * @property {string} originalname * @property {string} encoding * @property {string} mimetype * @property {Buffer} buffer * @property {string} size */ allowedMimeTypes = [ "image/*" ]; /** * @param {string} instanceId * @param {string} albumId * @param {FileUploadInfo[]} files * @return {Promise} */ async uploadPhotos(instanceId, albumId, files) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } const disallowedMimetypeFiles = this.#getDisallowedMimeTypeFiles(this.allowedMimeTypes, files); if (disallowedMimetypeFiles.length > 0) throw new BadRequestError(`The following files have an incorrect mimetype ${disallowedMimetypeFiles.map(f => `[${f.mimetype}]: ${f.fieldname}`).join("; ")}`); album.photos = album.photos || []; let sortOrder = album.photos.at(-1)?.sortOrder + 1 || 1; // Upload file /** @type {{photo: Photo, buffer: Buffer}[]} **/ const newPhotos = files.map(file => ({ photo: new Photo({ albumId: albumId, photoId: cleanForId(file.originalname), fileName: file.originalname, mimeType: file.mimetype, name: file.originalname.substring(0, file.originalname.lastIndexOf(".")), sortOrder: sortOrder++ }), buffer: file.buffer })); const duplicatePhotos = this.#getDuplicatePhotos(album.photos, newPhotos.map(({photo}) => photo)); if (duplicatePhotos.length > 0) throw new BadRequestError(`The following files already exist ${duplicatePhotos.map(photo => `[${photo.name}]`).join("; ")}, delete the existing photo or rename the file`); for (let {photo, buffer} of newPhotos) { photo.s3Path = await minio.uploadFile(buffer, { path: album.albumId, name: photo.fileName, type: photo.mimeType }); } album.photos.push(...newPhotos.map(({photo}) => photo)); await db.albums.update(album); return album; } async deletePhoto(instanceId, albumId, photoId) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } const indexToDelete = album.photos?.findIndex(x => x.photoId === photoId) if (indexToDelete < 0) { throw new NotFoundError("Photo not found"); } const photo = album.photos[indexToDelete]; if (photo.s3Path) { try { await minio.deleteFile(photo.s3Path); } catch (_) { } } album.photos = album.photos.filter(photo => photo.photoId !== photoId); await db.albums.update(album); return album; } async patchPhoto(instanceId, albumId, photoId, patchOperations) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } const photoIndex = album.photos?.findIndex(x => x.photoId === photoId); if (photoIndex < 0) { throw new NotFoundError("Photo not found"); } const patchPaths = patchOperations.map(op => op.path); const allowedPaths = ["/name", "/description"]; const notAllowedPatches = patchPaths.filter(path => allowedPaths.indexOf(path) < 0) if(notAllowedPatches.length > 0) { throw new BadRequestError(`The following paths are invalid: ${notAllowedPatches.map(p => p.path).join(", ")}`) } album.photos[photoIndex] = new Photo(jsonPatch.applyPatch(album.photos[photoIndex], patchOperations)); try { await db.albums.update(album); console.log("Album updated", album) return album; } catch(err) { if(err.message?.toLowerCase().indexOf("duplicate key") >= 0) { throw new BadRequestError("Duplicate name"); } throw new ApiError(err); } } /** * @param {string} instanceId * @param {string} albumId * @param {string} photoId * @param {"up"|"down"} direction * @return {Promise} */ async movePhoto(instanceId, albumId, photoId, direction) { const album = await db.albums.findOne({instanceId, albumId}); if (!album) { throw new NotFoundError("Album not found"); } const photoIndex = album.photos?.findIndex(x => x.photoId === photoId); if (typeof photoIndex === "undefined" || photoIndex < 0) throw new NotFoundError("Photo not found") const photo = album.photos[photoIndex]; const initialSortOrder = photo.sortOrder; const photoIndexToMove = album.photos.findIndex(x => x.sortOrder === initialSortOrder + (direction === "up" ? -1 : 1)); if (typeof photoIndexToMove === "undefined" || photoIndexToMove < 0) throw new BadRequestError(`Couldn't move photo ${direction}, photo's sortOrder is ${initialSortOrder}`); const photoToMove = album.photos[photoIndexToMove]; photo.sortOrder = photoToMove.sortOrder; photoToMove.sortOrder = initialSortOrder; album.photos = album.photos.sort((a, b) => a.sortOrder - b.sortOrder); await db.albums.update(album); return album; } #getDisallowedMimeTypeFiles(allowedTypes, files) { return files.filter(file => { const split = file.mimetype.split("/"); return allowedTypes.filter(a => a === file.mimetype || a === `${split[0]}/*`).length === 0; }); } #getDuplicatePhotos(existingPhotos, newPhotos) { return newPhotos.filter(photo => existingPhotos.filter(ePhoto => ePhoto.photoId === photo.photoId).length > 0 ); } } const instance = new AlbumsService(); export {instance as albums};