298 lines
11 KiB
JavaScript
298 lines
11 KiB
JavaScript
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<boolean>}
|
|
*/
|
|
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<Album>}
|
|
*/
|
|
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<Album>}
|
|
*/
|
|
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}; |