Initial commit

This commit is contained in:
2024-04-17 22:54:13 +02:00
commit a8fdb455f4
162 changed files with 49158 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/node_modules
/dist
+10
View File
@@ -0,0 +1,10 @@
FROM node:21.2.0
# Create app directory
WORKDIR /usr/src/photos-server.novox.be
# Copy build output
COPY ./dist ./
COPY ./node_modules ./node_modules
CMD ["node", "server.cjs", "--enable-source-maps"]
+3553
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "photos-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "npm-run-all -p watch nodemon",
"nodemon": "nodemon --watch dist --enable-source-maps --inspect dist/server.cjs",
"start": "node src/server.cjs",
"build": "rollup --config",
"watch": "rollup --config --watch",
"docker-build": "./scripts/docker-build.sh",
"docker-run": "./scripts/docker-run.sh"
},
"dependencies": {
"@babel/preset-typescript": "^7.23.3",
"@koa/multer": "^3.0.2",
"@koa/router": "^12.0.1",
"image-size": "^1.0.2",
"koa": "^2.14.2",
"koa-bodyparser": "^4.4.1",
"minio": "^7.1.3",
"mongodb": "^6.3.0",
"multer": "^1.4.5-lts.1",
"reflect-metadata": "^0.1.13",
"sharp": "^0.33.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-json": "^6.0.1",
"@rollup/plugin-node-resolve": "^15.2.3",
"nodemon": "^3.0.1",
"rollup": "^4.5.2"
}
}
+26
View File
@@ -0,0 +1,26 @@
// rollup.config.mjs
import nodeResolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
export default {
input: 'src/server.js',
output: [{
file: 'dist/server.mjs',
format: 'esm',
sourcemap: true
}, {
file: 'dist/server.cjs',
format: 'cjs',
sourcemap: true
}],
plugins: [
commonjs({
include: /node_modules/,
requireReturnsDefault: 'auto',
ignoreDynamicRequires: true
}),
nodeResolve(),
json()
]
};
+3
View File
@@ -0,0 +1,3 @@
#/bin/bash
docker build --tag photos-server .
+6
View File
@@ -0,0 +1,6 @@
#/bin/bash
docker stop photos-api
docker rm photos-api
docker run -d -p 9191:9001 -e PORT=9001 --name photos-api photos-api
+20
View File
@@ -0,0 +1,20 @@
/**
* @typedef {Object} AuthorizeOptions
* @property {string} [role] - Role which the user should be in
* @property {string[]} [roles] - Array of roles which the user should be in
*/
export class AuthorizeOptions {
/** @type {?string} **/
role;
/** @type {string[]} **/
roles;
constructor(roles) {
if(typeof roles === 'string') {
this.role = roles;
}
if (roles instanceof Array) {
this.roles = roles;
}
}
}
+154
View File
@@ -0,0 +1,154 @@
function normalizeIp(ip){
// Oddly enough, FF passing in the clientIp is different from chrome?!
return ip === "::ffff:127.0.0.1" ? "::1" : ip;
}
/**
* @typedef File
* @property {Buffer} buffer
* @property {string} encoding
* @property {string} fileName
* @property {string} mimetype
*/
/**
* Request context
*/
export class RequestContext{
/** @param {RequestContext} c */
constructor(c){
if(c){
this.parameters = c.parameters??{};
this.queryParameters = c.queryParameters??{};
this.body = c.body;
this.session = c.session;
this.method = c.method;
this.endpoint = c.endpoint;
this.clientIp = c.clientIp;
this.host = c.host;
this.headers = c.headers;
}
}
/** @type {Object<string,string>} */
parameters = {};
/** @type {Object<string,string>} */
queryParameters = {};
/** @type {*} */
body = undefined;
/** @type {AuthSession} */
session;
/** @type {'GET'|'POST'|'PATCH'|'DELETE'|'PUT'} */
method;
/** @type {string} */
endpoint;
/** @type {string} */
clientIp;
/** @type {string} */
host;
/** @type {Object} */
headers;
/** @type {string} */
rawBody = undefined;
get queryString(){
let query = Array.from(Object.entries(this.queryParameters));
if(query.length>0){
return "?" + query.map(
([key,value])=>
value!==null&&value!==undefined
? [encodeURIComponent(key), encodeURIComponent(value)].join('=')
: encodeURIComponent(key)
).join("&");
}else{
return "";
}
}
}
export class KoaRequestContext extends RequestContext{
constructor(koaCtx){
super({
parameters: koaCtx.params,
queryParameters: koaCtx.query,
body: koaCtx.request?.body,
rawBody: koaCtx.request?.rawBody,
session: koaCtx.state?.session,
method: koaCtx.method,
endpoint: koaCtx.path? koaCtx.path.slice((koaCtx.path.indexOf("/api/")??0) + '/api'.length) : '/',
clientIp: normalizeIp((
koaCtx.request.headers["X-Forwarded-For".toLowerCase()]
?? koaCtx.request.headers["X-Real-IP".toLowerCase()]
?? koaCtx.request.headers["X-Orig-IP".toLowerCase()]
?? koaCtx.request.ip
)?.toString() ?? undefined),
host: (koaCtx.request.headers["host"])?.toString() ?? undefined,
headers: koaCtx.request.headers
});
this.#koaCtx = koaCtx;
}
// Private
#koaCtx;
set(key, value) {
this.#koaCtx.set(key, value);
}
/** @type {request.Request} */
get req(){
return this.#koaCtx.req;
}
/** @type {request.Response} */
get res(){
return this.#koaCtx.res;
}
/** @type {string} */
get path(){
return this.#koaCtx.path;
}
set attachment(fileName){
return this.#koaCtx.attachment(fileName);
}
set type(type){
return this.#koaCtx.type = type;
}
/** @type {File} */
get file(){
return this.#koaCtx.file;
}
/** @type {[File]} */
get files(){
return this.#koaCtx.files;
}
set status(value) {
this.#koaCtx.status = value;
}
/** @type {Instance} */
get currentInstance(){
return this.#koaCtx.currentInstance;
}
/** @param {Instance} value */
set currentInstance(value) {
this.#koaCtx.currentInstance = value;
}
/** @type {AuthorizationInfo} */
get authorizationInfo() {
return this.#koaCtx.authorizationInfo;
}
set authorizationInfo(authorizationInfo) {
this.#koaCtx.authorizationInfo = authorizationInfo;
}
/** @type {UserInfo} */
get userInfo() {
return this.#koaCtx.userInfo;
}
set userInfo(userInfo) {
this.#koaCtx.userInfo = userInfo;
}
}
+110
View File
@@ -0,0 +1,110 @@
import "reflect-metadata";
/**
* @typedef {'GET'|'POST'|'PATCH'|'DELETE'|'PUT'} HttpMethod
*/
/**
* @typedef {EndpointOptions}
* @property {[function]} middlewares - Additional middlewares
* @property {AuthorizeOptions} authorization - Authorization info
*/
export class EndpointOptions {
/** @type {function[]} **/
middlewares = [];
/** @type {?AuthorizeOptions} **/
authorizeOptions;
/**
* @param {function[]} middlewares
* @param {?AuthorizeOptions=} [authorizeOptions]
*/
constructor(middlewares, authorizeOptions= null) {
this.middlewares = middlewares;
this.authorizeOptions = authorizeOptions;
}
}
/**
* @typedef {HttpEndpoint}
* @property {HttpMethod} method
* @property {string} route
* @property {?EndpointOptions} options
*/
export class HttpEndpoint {
/** @type {HttpMethod} **/
method;
/** @type {string} **/
route;
/** @type {?EndpointOptions} **/
options;
/** @type {function(KoaRequestContext, function)} **/
handler;
/**
* @param {HttpMethod} method
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(method, route, handler, options = null) {
this.method = method;
this.route = route;
this.handler = handler;
this.options = options;
}
}
export class HttpGet extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('GET', route, handler, options);
}
}
export class HttpPost extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('POST', route, handler, options);
}
}
export class HttpPatch extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('PATCH', route, handler, options);
}
}
export class HttpPut extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('PUT', route, handler, options);
}
}
export class HttpDelete extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('DELETE', route, handler, options);
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./authorize";
export * from "./endpoint";
export * from "./router.js";
export * from "./context";
+104
View File
@@ -0,0 +1,104 @@
import {KoaRequestContext} from "./context";
import {hasRole, hasSite} from "../../middleware/authorization";
import Router from "@koa/router";
/**
*
* @param {string} path
*/
export class ApiRouter {
/** @type {string} **/
path;
/** @type {HttpEndpoint[]} **/
endpoints = [];
/** @type {?AuthorizeOptions} **/
authorizeOptions;
/** @type {Router} **/
koaRouter;
routerAuthValidator;
/**
* @param {string} path
* @param {?AuthorizeOptions=} [authorizeOptions]
*/
constructor(path, authorizeOptions = null) {
this.path = path;
// Define a matching router
this.koaRouter = new Router();
this.authorizeOptions = authorizeOptions;
this.routerAuthValidator = this.generateAuthorizeHandler(authorizeOptions);
}
/**
* @param {HttpEndpoint} endpoint
*/
registerEndpoint(endpoint) {
let method = endpoint.method;
let route = endpoint.route;
let authValidator = this.generateAuthorizeHandler(endpoint.options?.authorizeOptions);
let handler = async (ctx, next) => {
let reqCtx = new KoaRequestContext(ctx);
if(this.routerAuthValidator) {
await this.routerAuthValidator(reqCtx);
}
if(authValidator) {
await authValidator(reqCtx);
}
ctx.body = await Promise.resolve(endpoint.handler(reqCtx, next));
};
let methodMapping = {
'GET': 'get',
'POST': 'post',
'PATCH': 'patch',
'PUT': 'put',
'DELETE': 'delete'
};
if(endpoint.options?.middlewares) {
this.koaRouter[methodMapping[method]](route, ...endpoint.options.middlewares, handler);
} else {
this.koaRouter[methodMapping[method]](route, handler);
}
}
/**
* @param {?AuthorizeOptions} authorizeOptions
* @returns {(function(*, *): Promise<void>)|*}
*/
generateAuthorizeHandler(authorizeOptions) {
if (authorizeOptions) {
return async (/** @param {KoaRequestContext} **/ctx) => {
if (authorizeOptions.role || authorizeOptions.roles) {
const roles = [
authorizeOptions.role,
...(authorizeOptions.roles || [])
].filter(x => x);
await hasRole(roles, ctx);
const {parameters: {site}} = ctx;
if(site){
await hasSite(site, ctx);
}
}
}
}
return null;
}
/**
* @param {Router} app
*/
register(app) {
// Register it as middleware in the app (which may in turn have been a KoaRouter)
app.use(this.path, this.koaRouter.routes(), this.koaRouter.allowedMethods());
}
}
+12
View File
@@ -0,0 +1,12 @@
import Router from "@koa/router";
import {publicAlbumsApi} from "./public/albums";
import {s3Api} from "./public/s3/index.js";
import {privateInstancesApi} from "./private/instances.js";
import {privateAlbumsApi} from "./private/albums.js";
export const api = new Router();
publicAlbumsApi.register(api);
s3Api.register(api);
privateInstancesApi.register(api);
privateAlbumsApi.register(api);
+154
View File
@@ -0,0 +1,154 @@
import {ApiRouter, AuthorizeOptions, HttpDelete, HttpGet, HttpPatch, HttpPost, HttpPut} from "../api-routing/index.js";
import {BadRequestError, ValidationError} from "../../models/errors/index.js";
import {albums} from "../../services/albums.js";
import multer from "@koa/multer";
import {instances} from "../../services/instances.js";
const upload = multer();
class AlbumsApi extends ApiRouter {
constructor() {
super("/instances/:instanceId", new AuthorizeOptions(["instance-manager", "admin"]));
this.registerEndpoint(new HttpGet("/albums", this.fetchAlbums));
this.registerEndpoint(new HttpPost("/albums", this.create));
this.registerEndpoint(new HttpPut("/albums/:albumId", this.update));
this.registerEndpoint(new HttpPatch("/albums/:albumId", this.patch));
this.registerEndpoint(new HttpDelete("/albums/:albumId", this.delete));
this.registerEndpoint(new HttpPost("/albums/:albumId/move-up", this.moveUp));
this.registerEndpoint(new HttpPost("/albums/:albumId/move-down", this.moveDown));
this.registerEndpoint(new HttpPost("/albums/:albumId/upload-photos", this.uploadPhotos, {
middlewares: [upload.array('photos')]
}
));
this.registerEndpoint(new HttpDelete("/albums/:albumId/photos/:photoId", this.deletePhoto));
this.registerEndpoint(new HttpPatch("/albums/:albumId/photos/:photoId", this.patchPhoto));
this.registerEndpoint(new HttpPost("/albums/:albumId/photos/:photoId/move-up", this.movePhotoUp));
this.registerEndpoint(new HttpPost("/albums/:albumId/photos/:photoId/move-down", this.movePhotoDown));
}
/**
* @param {KoaRequestContext} ctx
*/
async fetchAlbums(ctx) {
const {parameters: {instanceId}} = ctx;
return {
data: await albums.getAlbums(instanceId)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async create({parameters: {instanceId}, body}) {
return {
data: await albums.createAlbum(instanceId, body)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async update({parameters: {instanceId, albumId}, body: {name, description, sort}}) {
sort = sort || "auto";
if (!["manual", "auto"].includes(sort)) {
throw new ValidationError([{
field: "sort",
message: `Parameter 'sort' can only be 'manual' or 'auto', not ${sort}.`
}]);
}
return {
data: await albums.updateAlbum({instanceId, albumId, name, description, sort})
};
}
/**
* @param {KoaRequestContext} ctx
*/
async patch({parameters: {instanceId, albumId}, body}) {
return {
data: await albums.patchAlbum(instanceId, albumId, body)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async delete(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
await albums.deleteAlbum(instanceId, albumId);
return {
data: await albums.getAlbums()
}
}
/**
* @param {KoaRequestContext} ctx
*/
async moveUp(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
return {
data: await albums.moveAlbum(instanceId, albumId, "up")
};
}
/**
* @param {KoaRequestContext} ctx
*/
async moveDown(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
return {
data: await albums.moveAlbum(instanceId, albumId, "down")
};
}
async uploadPhotos(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
if (!ctx.files) {
throw new BadRequestError("No files were uploaded for field 'photos'");
}
return {
data: await albums.uploadPhotos(instanceId, albumId, ctx.files)
};
}
async deletePhoto(ctx) {
const {parameters: {instanceId, albumId, photoId}} = ctx;
return {
data: await albums.deletePhoto(instanceId, albumId, photoId)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async patchPhoto({parameters: {instanceId, albumId}, body}) {
return {
data: await albums.patchPhoto(instanceId, albumId, parameters.photoId, body)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async movePhotoUp(ctx) {
const {parameters: {instanceId, albumId, photoId}} = ctx;
return {
data: await albums.movePhoto(instanceId, albumId, photoId, "up")
};
}
/**
* @param {KoaRequestContext} ctx
*/
async movePhotoDown(ctx) {
const {parameters: {instanceId, albumId, photoId}} = ctx;
return {
data: await albums.movePhoto(instanceId, albumId, photoId, "down")
};
}
}
// Singleton
const instance = new AlbumsApi();
export {instance as privateAlbumsApi};
+75
View File
@@ -0,0 +1,75 @@
import {ApiRouter, AuthorizeOptions, HttpGet, HttpPatch, HttpPost, HttpPut} from "../api-routing/index.js";
import {instances} from "../../services/instances.js";
import {BadRequestError} from "../../models/errors/index.js";
import {albums} from "../../services/albums.js";
import multer from "@koa/multer";
const upload = multer();
class InstancesApi extends ApiRouter {
constructor() {
super("/instances", new AuthorizeOptions(["instance-manager", "admin"]));
this.registerEndpoint(new HttpGet("/", this.fetchInstances));
this.registerEndpoint(new HttpPost("/", this.createInstance, {
authorizeOptions: new AuthorizeOptions("admin")
}));
this.registerEndpoint(new HttpGet("/:instanceId", this.fetchInstance));
this.registerEndpoint(new HttpPut("/:instanceId", this.updateInstance));
this.registerEndpoint(new HttpPost("/:instanceId/upload-cover-photo", this.uploadPhoto, {
middlewares: [upload.array('cover-photo')]
}
));
}
/**
* @param {KoaRequestContext} ctx
*/
async fetchInstances(ctx) {
return {
data: await instances.getInstances(ctx.userInfo.available_photos_sites)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async fetchInstance(ctx) {
const {parameters: {instanceId}} = ctx;
return {
data: await instances.getInstance(instanceId)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async updateInstance({parameters, body}) {
return {
data: await instances.updateInstance({
...body,
instanceId: parameters.instanceId
})
};
}
/**
* @param {KoaRequestContext} ctx
*/
async createInstance({body}) {
return {
data: await instances.createInstance(body)
};
}
async uploadPhoto(ctx) {
const {parameters: {instanceId}} = ctx;
if (!ctx.files) {
throw new BadRequestError("No file was uploaded for field 'photo'");
}
return {
data: await instances.setCoverPhoto(instanceId, ctx.files[0])
};
}
}
// Singleton
const instance = new InstancesApi();
export {instance as privateInstancesApi};
+25
View File
@@ -0,0 +1,25 @@
import {ApiRouter, HttpGet} from "../../api-routing";
import {albums} from "../../../services/albums.js";
class PublicAlbumsApi extends ApiRouter {
constructor() {
super("/albums");
this.registerEndpoint(new HttpGet("/", this.getAll))
}
/**
* @param {KoaRequestContext} ctx
*/
async getAll(ctx) {
return {
data:{
albums: await albums.getAlbums(ctx.currentInstance.instanceId),
instance: ctx.currentInstance
}
};
}
}
// Singleton
const instance = new PublicAlbumsApi();
export {instance as publicAlbumsApi};
+61
View File
@@ -0,0 +1,61 @@
import {ApiRouter, HttpGet} from "../../api-routing";
import {minio} from "../../../services/minio.js";
import {thumbnailer} from "../../../services/thumbnailer.js";
class S3Api extends ApiRouter {
constructor() {
super("/s3");
this.registerEndpoint(new HttpGet("/:filePath(.*)", this.getFile))
}
/**
* @param {KoaRequestContext} ctx
*/
async getFile(ctx) {
const {parameters: {filePath}, queryParameters: {width, height, percentage, fit}} = ctx;
const stream = await minio.downloadFile(filePath);
const headersToCopy = [
"content-type",
"content-length",
"last-modified",
"etag"
];
for(let header in stream.headers) {
if(headersToCopy.includes(header)) {
ctx.set(header, stream.headers[header]);
}
}
const expiryDate = new Date();
expiryDate.setSeconds(expiryDate.getSeconds() + 10);
ctx.set("expires", expiryDate.toISOString());
ctx.set("content-location", filePath);
ctx.set("cache-control", "public, max-age=10");
ctx.set("date", stream.headers["last-modified"]);
for(let header in ctx.headers) {
if(header.toLowerCase() === "if-none-match" &&
ctx.headers[header] === stream.headers["etag"]) {
ctx.status = 304;
return null;
} else if(header.toLowerCase() === "if-modified-since" &&
ctx.headers[header] === stream.headers["last-modified"]) {
ctx.status = 304;
return null;
}
}
if(width || height || percentage) {
// return thumbnail
const options = {};
if(!isNaN(Number(width))) options.width = Number(width);
if(!isNaN(Number(height))) options.height = Number(height);
if(!isNaN(Number(percentage))) options.percentage = Number(percentage);
if(fit) options.fit = fit;
return await thumbnailer.fromStream(stream, options, fit);
}
return stream;
}
}
// Singleton
const instance = new S3Api();
export {instance as s3Api};
+78
View File
@@ -0,0 +1,78 @@
const env = envValue("NODE_ENV", "development");
export const config = {
port: envNumber("PORT", 9001),
db: {
url: envValue("MONGO_URL", "mongodb://photos-local:PKCTca59%21%24VWtP@novox.be:27017/photos-local"), // Must be url-encoded
name: envValue("MONGO_DB", "photos-local")
},
environment: env,
is_debug: env === "development",
minio: {
endpoint: envValue("MINIO_ENDPOINT", "files-api.novox.be"),
port: envNumber("MINIO_PORT", 443),
useSSL: envBoolean("MINIO_USE_SSL", true),
accesskey: envValue("MINIO_ACCESSKEY", "KZUX0UDX6GUT9GPGI1IY"),
secret: envValue("MINIO_SECRET", "YwPoqaCfFc0nuhDDhVkllY0ijbr5hdQaqwY83s1s"),
bucket: envValue("MINIO_BUCKET", "photos-local"),
},
openId: {
host: envValue("OPENID_REALM", "keycloak.novox.be"),
realm: envValue("OPENID_REALM", "Novox")
},
superAdminKey: envValue("SUPER_ADMIN_KEY", "why-is-this-possible*&?")
};
/**
* Get a (string) value out of the ENV-vars
*
* @param {string} name
* @param {string} defaultValue
* @return {string}
*/
function envValue(name, defaultValue) {
let value = defaultValue;
let envVal = process.env[name];
if (envVal !== undefined)
value = envVal;
return value;
}
/**
* Get a numeric value out of the ENV-vars
*
* @param {string} name
* @param {number} defaultValue
* @return {number}
*/
function envNumber(name, defaultValue) {
let value = defaultValue;
let envVal = process.env[name];
if (envVal !== undefined) {
value = Number.parseFloat(envVal);
if (Number.isNaN(value))
throw new Error(`ENV-Value '${name}': ${envVal} not recognized as a valid numeric value`);
}
return value;
}
/**
* Get a boolean value out of the ENV-vars
*
* @param {string} name
* @param {boolean} defaultValue
* @return {boolean}
*/
function envBoolean(name, defaultValue) {
let value = defaultValue;
let envVal = process.env[name];
if (envVal !== undefined) {
envVal = envVal.toLowerCase();
if (envVal === 'true' || envVal === '1')
value = true;
else if (envVal === 'false' || envVal === '0')
value = false;
else
throw new Error(`ENV-Value '${name}': ${envVal} not recognized as a valid boolean value`);
}
return value;
}
@@ -0,0 +1,43 @@
import {UnauthorizedError, AuthorizationInfo, SuperAdmin, UserInfo} from "../../models";
import {JwtService} from "../../services";
import {config} from "../../config.js";
/**
* @param {KoaRequestContext} ctx
* @param next
* @returns {Promise<*>}
*/
export async function handleAuthorization(ctx, next) {
const authorizationHeader = ctx.request.req.headers["authorization"];
if (!authorizationHeader) {
return await next();
}
if (authorizationHeader.startsWith("super-admin")) {
if (authorizationHeader.endsWith(config.superAdminKey)) {
ctx.authorizationInfo = new SuperAdmin({});
} else {
throw new UnauthorizedError();
}
} else {
try {
// Validate JWT token
const options = {
method: 'GET',
url: `https://${config.openId.host}/realms/${config.openId.realm}/protocol/openid-connect/userinfo`,
headers: {
Authorization: authorizationHeader,
},
};
let response;
response = await fetch(options.url, options);
// if the request status isn't "OK", the token is invalid
if (response.status === 200) {
ctx.userInfo = new UserInfo(await response.json());
ctx.authorizationInfo = new AuthorizationInfo(JwtService.decode(authorizationHeader));
}
} catch (err) {}
// We intentionally don't throw an exception because you can access public routes with an invalid token
}
return await next();
}
+25
View File
@@ -0,0 +1,25 @@
import {UnauthorizedError} from "../../models/index.js";
export async function hasRole(roles, ctx) {
let hasRole = false;
for (let role of roles) {
if(ctx.authorizationInfo?.realmAccess?.roles?.indexOf(role) >= 0) {
hasRole = true;
}
if(ctx.authorizationInfo?.resourceAccess?.photos?.roles?.indexOf(role) >= 0) {
hasRole = true;
}
}
if(!hasRole) {
throw new UnauthorizedError()
}
}
export async function hasSite(site, ctx) {
let hasSite = false;
if(ctx.userInfo?.available_photos_sites?.indexOf(site) >= 0) {
hasSite = true;
}
if(!hasSite) {
throw new UnauthorizedError()
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./handle-authorization.js";
export * from "./has-role.js";
+57
View File
@@ -0,0 +1,57 @@
import * as vary from "./vary.js";
export async function handleCors(ctx,next){
// If the Origin header is not present terminate this set of steps.
const requestOrigin = ctx.get('Origin');
// Always set Vary header
ctx.vary('Origin');
if (!requestOrigin) return await next();
let origin = requestOrigin;
const headersSet = {};
function set(key, value) {
ctx.set(key, value);
headersSet[key] = value;
}
if (ctx.method !== 'OPTIONS') {
// Simple Cross-Origin Request, Actual Request, and Redirects
set('Access-Control-Allow-Origin', origin);
set('Access-Control-Allow-Credentials', 'true');
set('access-control-expose-headers', 'Content-Disposition');
try {
return await next();
} catch (err) {
const errHeadersSet = err.headers || {};
const varyWithOrigin = vary.append(errHeadersSet.vary || errHeadersSet.Vary || '', 'Origin');
delete errHeadersSet.Vary;
err.headers = Object.assign({}, errHeadersSet, headersSet, { vary: varyWithOrigin });
throw err;
}
} else {
// Preflight Request
// If there is no Access-Control-Request-Method header or if parsing failed,
// do not set any additional headers and terminate this set of steps.
// The request is outside the scope of this specification.
if (!ctx.get('Access-Control-Request-Method')) {
// this not preflight request, ignore it
return await next();
}
ctx.set('Access-Control-Allow-Origin', origin);
ctx.set('Access-Control-Allow-Credentials', 'true');
set('Access-Control-Allow-Methods', '*');
ctx.set('Access-Control-Allow-Headers', ctx.get('Access-Control-Request-Headers'));
ctx.status = 204;
}
}
+140
View File
@@ -0,0 +1,140 @@
/*!
* vary
* Copyright(c) 2014-2017 Douglas Christopher Wilson
* MIT Licensed
*/
/**
* RegExp to match field-name in RFC 7230 sec 3.2
*
* field-name = token
* token = 1*tchar
* tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
* / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
* / DIGIT / ALPHA
* ; any VCHAR, except delimiters
*/
const FIELD_NAME_REGEXP = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
/**
* Append a field to a vary header.
*
* @param {String} header
* @param {String|Array} field
* @return {String}
* @public
*/
export function append(header, field) {
if (typeof header !== 'string') {
throw new TypeError('header argument is required')
}
if (!field) {
throw new TypeError('field argument is required')
}
// get fields array
let fields = !Array.isArray(field)
? parse(String(field))
: field;
// assert on invalid field names
for (let j = 0; j < fields.length; j++) {
if (!FIELD_NAME_REGEXP.test(fields[ j ])) {
throw new TypeError('field argument contains an invalid header name')
}
}
// existing, unspecified vary
if (header === '*') {
return header
}
// enumerate current values
let val = header;
let vals = parse(header.toLowerCase());
// unspecified vary
if (fields.indexOf('*') !== -1 || vals.indexOf('*') !== -1) {
return '*'
}
for (let i = 0; i < fields.length; i++) {
let fld = fields[ i ].toLowerCase();
// append value (case-preserving)
if (vals.indexOf(fld) === -1) {
vals.push(fld);
val = val
? val + ', ' + fields[ i ]
: fields[ i ]
}
}
return val
}
/**
* Parse a vary header into an array.
*
* @param {String} header
* @return {Array}
* @private
*/
export function parse(header) {
let end = 0;
let list = [];
let start = 0;
// gather tokens
for (let i = 0, len = header.length; i < len; i++) {
switch (header.charCodeAt(i)) {
case 0x20: /* */
if (start === end) {
start = end = i + 1
}
break;
case 0x2c: /* , */
list.push(header.substring(start, end));
start = end = i + 1;
break;
default:
end = i + 1;
break
}
}
// final token
list.push(header.substring(start, end));
return list
}
/**
* Mark that a request is varied on a header field.
*
* @param {Object} res
* @param {String|Array} field
* @public
*/
export function vary(res, field) {
if (!res || !res.getHeader || !res.setHeader) {
// quack quack
throw new TypeError('res argument is required')
}
// get existing header
let val = res.getHeader('Vary') || '';
let header = Array.isArray(val)
? val.join(', ')
: String(val);
// set new header
if ((val = append(header, field))) {
res.setHeader('Vary', val)
}
}
@@ -0,0 +1,11 @@
import {instances} from "../../services/instances.js";
/**
* @param {KoaRequestContext} ctx
* @param next
* @returns {Promise<*>}
*/
export async function handleCurrentInstance(ctx, next) {
ctx.currentInstance = await instances.findByUrlCached(ctx.req.headers.origin);
await next();
}
+39
View File
@@ -0,0 +1,39 @@
import {config} from "../../config.js";
import {NotFoundError, errorReviver, ApiError} from "../../models/errors";
export async function handleApiError(ctx,next){
try{
await next();
// Throw a 404-error if nothing handled the request by now
const status = ctx.status || 404;
if (status === 404) {
throw new NotFoundError();
}
}catch(err){
// Respond errors in JSON format
ctx.status = err.statusCode || err.status || 500;
if(ctx.status === 500){
// Unexpected error, log to console
console.error(err);
}
if(ctx.status===401){
ctx.headers['WWW-Authenticate'] = 'Bearer';// Indicate we use bearer tokens to authenticate
}
if(!(err instanceof ApiError)){
err = errorReviver(err);// looks weird
}
if((err.expose||config.is_debug) && err.message){
delete err.expose;
}else{
delete err.stack;
delete err.expose;
}
ctx.body = err;
// TODO Validation error-handling...
}
}
+38
View File
@@ -0,0 +1,38 @@
export class HrTimer{
// Private Properties
#markings = {
[undefined]: {
start: process.hrtime()
}
};
// Methods
stop(marker){
// TODO if stop is called with marker==undefined >> stop all markers
let markerTiming = this.#markings[marker] || (
this.#markings[marker] = { start: process.hrtime() }
);
markerTiming.end = process.hrtime();
return HrTimer.getMs(markerTiming.start,markerTiming.end);
}
start(marker, data){
// TODO if start is called with marker===undefined >> clear all previous markers from start/stop data (or just clear all markers)
this.#markings[marker] = {
start: process.hrtime(),
data: data
}
}
getDuration(marker){
let markerTiming = this.#markings[marker];
if(!markerTiming) return undefined; // no value, marker not found
let start = markerTiming.start;
let end = markerTiming.end || process.hrtime();
return getMs(start,end);
}
// Private
static getMs(start, end){
return ((end[0]-start[0]) * 1000) + ((end[1]-start[1]) / 1000000);
}
}
+87
View File
@@ -0,0 +1,87 @@
import { HrTimer } from "./hr-timer.js";
import {KoaRequestContext} from "../../api/api-routing";
const methodAnsis = {
'GET': '',
'POST': '',
'PATCH': '',
'PUT': '',
'DELETE': '',
};
const statusAnsis = {
200: '',
400: '',
401: '',
403: '',
404: '',
500: '',
501: '',
};
const ansiCloser = ``;
function padRight(str, length, char){
if(!(typeof(str)==='string')) return str;
if(!char) char = ' ';
while(str.length<length){
str += char;
}
return str;
}
function padLeft(str, length, char){
if(!(typeof(str)==='string')) return str;
if(!char) char = ' ';
while(str.length<length){
str = char + str;
}
return str;
}
function requestInfo(ctx, duration) {
let now = new Date();
return `${[
now.getDate(),
now.getMonth() + 1
].map(x => ("00" + x).slice(-2)).join('/')
} ${[
now.getHours(),
now.getMinutes(),
now.getSeconds(),
].map(x => ("00" + x).slice(-2)).join(':')
} - [${methodAnsis[ ctx.method ] || ''}${ctx.method}${ansiCloser}]\t${padRight(ctx.path,40)}`;
}
function responseInfo(ctx, duration) {
return [
`${statusAnsis[ ctx.res?.statusCode ]||''}${ctx.res?.statusCode || 404}:${ctx.res?.statusMessage || "Not Found"}${ansiCloser}`,
`${padLeft((Math.round(duration * 10) / 10).toFixed(1), 5)}ms`,
ctx.state.root && ctx.state.root!=='default'? `${ctx.state.root}${ansiCloser}`:null
].filter(x=>x).join(' - ');
}
let lastPath = null;
/**
* Log requests
*/
export async function logRequest(ctx, next) {
let timer = new HrTimer();
try {
let result = await next();
let duration = timer.stop();
let requestCtx = new KoaRequestContext(ctx);// TODO better solution
if(!ctx.path?.startsWith("/service-worker.js") || ctx.path!==lastPath) {// Surpress subsequent SW calls
console.log(
[
requestInfo(ctx, duration),
responseInfo(ctx, duration) + ' ',
requestCtx.queryString ? `Query: ${requestCtx.queryString}${ansiCloser}` : null
].filter(x => x).join(' - '));
}
lastPath = ctx.path;
return result;
} catch (err) {
let duration = timer.stop();
console.error(`${requestInfo(ctx, duration)} - ${responseInfo(ctx, duration)}:${err.toString()}`);
}
}
+25
View File
@@ -0,0 +1,25 @@
export class AuthorizationInfo {
/**
* @param {AuthorizationInfo} c
*/
constructor(c) {
this.realmAccess = c.realmAccess || c.realm_access;
this.resourceAccess = c.resourceAccess || c.resource_access;
}
}
export class UserInfo {
/**
* @param {UserInfo} c
*/
constructor(c) {
this.sub = c.sub;
this.email_verified = c.email_verified;
this.name = c.name;
this.preferred_username = c.preferred_username;
this.given_name = c.given_name;
this.family_name = c.family_name;
this.email = c.email;
this.available_photos_sites = c.available_photos_sites;
}
}
+12
View File
@@ -0,0 +1,12 @@
import {AuthorizationInfo} from "./authorization-info.js";
export class SuperAdmin extends AuthorizationInfo {
/**
* @param {SuperAdmin} c
*/
constructor(c) {
super(c);
this.realmAccess = {"roles":["instance-manager", 'admin']};
this.resourceAccess = {"photos":{"roles":["instance-manager", 'admin']}};
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Describes an error thrown from the API
*/
export class ApiError extends Error {
/**
* @type {string}
*/
message;
/**
* @type {string}
*/
stack;
/**
* @param {ApiError} err
*/
constructor(err = null) {
super(err?.message??(typeof(err)==='string'?err:'API-Error'));
}
/**
* Http-status code (like 404, 500, 501, ...)
* @type {number}
*/
status;
/**
* Enum-code describing the type of error (e.g. TOKEN_EXPIRED)
* @type {string}
*/
code;
// Request info (only used client-side)
/**
* @type {string}
*/
api;
/**
* @type {string}
*/
endPoint;
/**
* @type {string}
*/
method;
}
+20
View File
@@ -0,0 +1,20 @@
import {ApiError} from "./api.js";
// TODO Validation errors would inherit from a BadRequest?!
/**
* Describes an error thrown from the API when a request is invalid
*/
export class BadRequestError extends ApiError{
status = 400;
code = "BAD_REQUEST";
i18n = "badRequest";
message = "Bad Request";
constructor(msg) {
super(msg);
if(typeof(msg) === "string") {
this.message = msg;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ApiError } from "./api.js";
/**
* Describes an error thrown from the API when a request is invalid
*/
export class ConflictError extends ApiError {
status = 409;
code = "CONFLICT";
i18n = "conflictError";
message = "Request is conflict with current resource";
}
+27
View File
@@ -0,0 +1,27 @@
import { ApiError } from "./api.js";
import { BadRequestError } from "./bad-request.js";
import { UnauthorizedError } from "./unauthorized.js";
import { ForbiddenError } from "./forbidden.js";
import { NotFoundError } from "./not-found.js";
import { InternalServerError } from "./internal-server.js";
import { NotImplementedError } from "./not-implemented.js";
import { ConflictError } from "./conflict-error.js";
const errorMapping = {
400: BadRequestError,
401: UnauthorizedError,
403: ForbiddenError,
404: NotFoundError,
409: ConflictError,
500: InternalServerError,
501: NotImplementedError
};
export function errorReviver(err) {
let errType = errorMapping[err?.status] || ApiError;
let errInstance = new errType(err);
if (err.stack) errInstance.stack = err.stack;
if (err.message) errInstance.message = err.message;
return errInstance;
}
+12
View File
@@ -0,0 +1,12 @@
import { ApiError } from "./api.js";
/**
* Describes an error thrown from the API when a user it not
*/
export class ForbiddenError extends ApiError{
status = 403;
code = "FORBIDDEN";
i18n = "forbidden";
message = "Forbidden";
}
+16
View File
@@ -0,0 +1,16 @@
// API
export * from "./api.js";
// 4xx
export * from "./bad-request.js";
export * from "./unauthorized.js";
export * from "./forbidden.js";
export * from "./not-found.js";
export * from "./conflict-error.js";
export * from "./validation-error.js";
// 5xx
export * from "./internal-server.js";
export * from "./not-implemented.js";
// NON-API
export * from "./permission-error.js";
// Reviver
export * from "./error-reviver.js";
+11
View File
@@ -0,0 +1,11 @@
import { ApiError } from "./api.js";
/**
* Describes an error thrown from the API when a request caused a server error
*/
export class InternalServerError extends ApiError {
status = 500;
code = "INTERNAL_SERVER_ERROR";
i18n = "internalServerError";
message = "Internal Server Error";
}
@@ -0,0 +1,5 @@
export class InvalidPatch extends Error {
}
export class PatchApplyError extends Error {}
+18
View File
@@ -0,0 +1,18 @@
import { ApiError } from "./api.js";
/**
* Describes an error thrown from the API when a resource is not found
*/
export class NotFoundError extends ApiError{
status = 404;
code = "NOT_FOUND";
i18n = "notFound";
message = "Not Found";
constructor(msg) {
super(msg);
if(typeof(msg) === "string") {
this.message = msg;
}
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ApiError } from "./api.js";
/**
* Describes an error thrown from the API when a request caused a server error
*/
export class NotImplementedError extends ApiError{
status = 501;
code = "NOT_IMPLEMENTED";
i18n = "notImplemented";
message = "Not Implemented Error";
}
+8
View File
@@ -0,0 +1,8 @@
import {ApiError} from "./api.js";
export class PermissionError extends ApiError {
constructor(message, permission){
super(message);
this.permission = permission;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ApiError } from "./api.js";
/**
* Describes an error thrown from the API when a user it not
*/
export class UnauthorizedError extends ApiError{
status = 401;
code = "UNAUTHORIZED";
i18n = "unauthorized";
message = "Unauthorized";
}
+26
View File
@@ -0,0 +1,26 @@
import {BadRequestError} from "./bad-request.js";
/**
* @class FieldError
* @property {string} field
* @property {string} message
*/
/**
* Describes an error thrown from the API when a request is invalid
*/
export class ValidationError extends BadRequestError{
/**
* @param {Array<FieldError>} validationErrors
*/
constructor(validationErrors) {
super();
this.validationErrors = validationErrors;
}
/** @type {Array<FieldError>} */
validationErrors = [];
status = 422;
code = "UNPROCESSABLE_ENTITY";
i18n = "unprocessableEntity";
message = "Unprocessable Entity";
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./authorization/super-admin.js";
export * from "./authorization/authorization-info.js";
export * from "./errors/unauthorized.js";
+34
View File
@@ -0,0 +1,34 @@
import {Photo} from "./photo.js";
export class Album {
/**
* @param {Album} album
*/
constructor(album) {
this._id = album?._id;
this.instanceId = album?.instanceId;
this.albumId = album?.albumId;
this.name = album?.name;
this.description = album?.description;
this.sort = album?.sort;
this.sortOrder = album?.sortOrder;
this.photos = album?.photos?.map(photo => new Photo(photo));
}
/** @type {string} **/
_id;
/** @type {string} **/
instanceId;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
description;
/** @type {"manual"|"auto"} **/
sort;
/** @type {number} **/
sortOrder;
/** @type {Photo[]} **/
photos;
}
+28
View File
@@ -0,0 +1,28 @@
import {Photo} from "./photo.js";
export class Instance {
/**
* @param {Instance} instance
*/
constructor(instance) {
this._id = instance?._id;
this.instanceId = instance?.instanceId;
this.title = instance?.title;
this.subtitle = instance?.subtitle;
this.coverPhoto = instance?.coverPhoto ? new Photo(instance?.coverPhoto) : null;
this.urls = instance?.urls || [];
}
/** @type {string} **/
_id;
/** @type {string} **/
instanceId;
/** @type {string} **/
title;
/** @type {string} **/
subtitle;
/** @type {Photo} **/
coverPhoto;
/** @type {string[]} **/
urls;
}
+28
View File
@@ -0,0 +1,28 @@
export class Photo {
/**
* @param {Photo} photo
*/
constructor(photo) {
this.photoId = photo?.photoId;
this.albumId = photo?.albumId;
this.name = photo?.name;
this.fileName = photo?.fileName;
this.mimeType = photo?.mimeType;
this.s3Path = photo?.s3Path;
this.description = photo?.description;
this.sortOrder = photo?.sortOrder;
}
/** @type {string} **/
photoId;
/** @type {string} **/
albumId;
/** @type {string} **/
name;
/** @type {string} **/
s3Path;
/** @type {string} **/
description;
/** @type {number} **/
sortOrder;
}
+91
View File
@@ -0,0 +1,91 @@
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import { api } from "./api";
import { config } from "./config.js";
import { handleApiError } from "./middleware/errors";
import { handleCors } from "./middleware/cors";
import { logRequest } from "./middleware/logger";
import Router from "@koa/router";
import http from "http";
import {handleAuthorization} from "./middleware/authorization/index.js";
import {db} from "./services/db/index.js";
import {handleCurrentInstance} from "./middleware/current-instance/handle-current-instance.js";
console.log("Booting the server");
const app = new Koa();
app.use(bodyParser({
enableTypes: ['json', 'form', 'text'],
extendTypes: {
text: ['text/html']
}
}));// Parse body as JSON or form-data
app.use(logRequest); // Log all requests
app.use(handleCors);// CORS handling
app.use(handleApiError);// JSON error-handling
app.use(handleAuthorization);// Handle authorization header
app.use(handleCurrentInstance);// Handle public api's current instance
// Register API-routes
const routes = new Router();
routes.use('/api', api.routes(), api.allowedMethods());
app.use(routes.routes());
app.use(routes.allowedMethods());
// Create the server
async function startServer() {
await db.start();// Bootstrap the mongo-connection (sad that this is needed, couldn't find a better way)
let server = http.createServer(app.callback());
let promisesCb = {
listen: undefined,
close: undefined
};
let promises = {
listen: new Promise((resolve, reject) => {
promisesCb.listen = { resolve, reject };
server.on('listening', () => {
delete promisesCb.listen;
resolve();
})
}),
close: new Promise((resolve, reject) => {
promisesCb.close = { resolve, reject };
server.on('close', () => resolve() && (promisesCb.listen))
})
};
server.on('error', (err) => {
if (promisesCb.listen)
promisesCb.listen.reject(err);
else
promisesCb.close.reject(err);
});
server = server.listen({ port: config.port });
await promises.listen;
let serverAddress = server.address();
if (!serverAddress) {
console.error("No server address?!");
} else {
let baseUrl = `http://${serverAddress.address !== '::' ? serverAddress.address : 'localhost'}:${serverAddress.port}`;
console.log(`Started ${baseUrl}!`);
}
await promises.close;
}
startServer().catch(err => {
if (err.code === 'EADDRINUSE') {
console.error(`Could not start server: port ${err.port} already in use.`);
} else {
console.error(err);
}
});
+298
View File
@@ -0,0 +1,298 @@
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};
+93
View File
@@ -0,0 +1,93 @@
import {MongoClient} from "mongodb";
import {config} from "../../config.js";
export class BaseDatabase {
async connect(){
let url = config.db.url;
this.client = new MongoClient(url, {
useNewUrlParser: true,
useUnifiedTopology: true
});
await this.client.connect();
this.db = this.client.db(config.db.name);
}
async init() {
await this.connect();
// Fill in the collections
if(this.collections?.length > 0){
for (const [prop, options] of this.collections) {
this[prop] = await this.ensureCollection(options.collectionName, options.options);
}
}
const initTasks = [];
// Fill in the typed-collections
if(this.typedCollections?.length > 0){
for (const [prop, typedCollection] of this.typedCollections) {
this[prop] = await new typedCollection(this);
if(this[prop]?.init){
initTasks.push(this[prop].init());
}
}
}
await Promise.all(initTasks);
}
async start() {
await this.init();
const collectionStartTasks = [];
// Fill in the typed-collections
if(this.typedCollections?.length > 0){
for (const [prop] of this.typedCollections) {
if(this[prop]?.start){
collectionStartTasks.push(this[prop].start());
}
}
}
await Promise.all(collectionStartTasks);
}
#typedCollections;
get typedCollections() {
return this.#typedCollections;
};
registerTypedCollection(name, collection) {
this.#typedCollections = Array.from(new Map([
...(this.#typedCollections || []),
[name, collection]
]));
}
get collections() {};
async disconnect(force) {
const disconnectTasks = [];
// Fill in the typed-collections
if(this.typedCollections?.length > 0){
for (const [prop] of this.typedCollections) {
if(this[prop]?.disconnect){
disconnectTasks.push(this[prop].disconnect());
}
}
}
await Promise.all(disconnectTasks);
return await this.client.close();
}
async ensureCollection(collectionName, options) {
try {
if(!(await this.db.listCollections({name: collectionName}).hasNext())) {
await this.db.createCollection(collectionName, options);
}
return this.db.collection(collectionName);
} catch(err) {
console.error(err);
}
}
client;
db;
}
@@ -0,0 +1,28 @@
import {TypedCollection} from "../models/index.js";
import {Album} from "../../../models/portfolio/album.js";
/**
* @class
* @extends {TypedCollection<Album>}
*/
export class Albums extends TypedCollection {
/**
* @param {PhotosDatabase} db
*/
constructor(db) {
super(db, {
model: Album,
collectionName: "albums",
indexes: [
{
key: {
'instanceId': 1,
'albumId': 1
},
unique: true,
name: "instanceAndAlbumId"
},
]
});
}
}
@@ -0,0 +1,27 @@
import {TypedCollection} from "../models/index.js";
import {Instance} from "../../../models/portfolio/instance.js";
/**
* @class
* @extends {TypedCollection<Instance>}
*/
export class Instances extends TypedCollection {
/**
* @param {PhotosDatabase} db
*/
constructor(db) {
super(db, {
model: Instance,
collectionName: "instances",
indexes: [
{
key: {
'instanceId': 1
},
unique: true,
name: "instanceId"
},
]
});
}
}
+22
View File
@@ -0,0 +1,22 @@
import {BaseDatabase} from "./base.js";
import {Albums} from "./collections/albums.js";
import {Instances} from "./collections/instances.js";
class PhotosDatabase extends BaseDatabase {
client;
db;
albums = Albums;
instances = Instances;
constructor() {
super();
this.registerTypedCollection("albums", this.albums);
this.registerTypedCollection("instances", this.instances);
}
}
const dbInstance = new PhotosDatabase();
export {dbInstance as db};
+2
View File
@@ -0,0 +1,2 @@
export * from "./typed-collection";
export * from "./typed-cursor";
+120
View File
@@ -0,0 +1,120 @@
import {ObjectId} from "mongodb";
class TypeMapperOptions { }
/**
* TODO: any update operations should be cross-checked with the model type (potentially extendable) to make sure _id references are always stored as ObjectId(..)
* same story for Date.
* ** MongoDb driver exposes a watch(...) function on the collection, this might be interesting to investigate further..
*
* @template T
* @class
*/
export class TypeMapper {
/**
* @param {T} model
* @param {TypeMapperOptions} opts
*/
constructor(model, opts) {
this.#model = model;
this.#options = opts||{};
}
/** @type {T} */
#model;
/** @type {TypeMapperOptions} */
#options;
mapToModel = (mongoResult) => {
if (mongoResult) {
if (mongoResult instanceof Array) {
return mongoResult.map(this.mapToModel);
} else if (typeof (mongoResult) === 'object') {
return new this.#model(mongoResult);
}
}
return mongoResult;
};
mapToMongo = (model) => {
if (model) {
if (model instanceof Array) {
return model.map(this.mapToMongo);
} else if (typeof (model) === 'object') {
const mongoModel = {};
for(let prop of Object.keys(model)){
if(typeof(model[prop]) === "undefined"){
continue;
}
if(Reflect.getMetadata("objectid", model, prop)) {
if(model[prop]){
mongoModel[prop] = new ObjectId(model[prop]);
} else {
mongoModel[prop] = null;
}
} else {
mongoModel[prop] = model[prop];
}
}
return mongoModel;
}
}
return model;
}
/**
* This function is used to clean-up $set operations
* TODO: this is just quickly invented as a way to fix any _id field nonsense, this should be expanded for all of a models properties... (like dates and stuff)
*/
mapMongoUpdate = (update) => {
update = {...update}; // Make a copy
if(update['$set']){
let setOp = {...update['$set']};// Make a copy
if(setOp._id && !(setOp._id instanceof ObjectId) && typeof(setOp._id)==='string' && ObjectId.isValid(setOp._id)){
setOp._id = new ObjectId(setOp._id);
}
update['$set'] = setOp;
}
return update;
};
/**
* This function is used to clean-up queries (making sure _id is a proper ObjectId for ex
* TODO: like mapMongoUpdate, this is just quickly invented to fix any _id field nonsense. It should be properly expanded
*/
mapMongoQuery = (query) => {
query = {...query}; // Make a copy
if(query._id && !(query._id instanceof ObjectId) && typeof(query._id)==='string' && ObjectId.isValid(query._id)){
query._id = new ObjectId(query._id);
}
return query;
}
/**
* Get a field from document
* @param {string} field - This may be a nested field e.g.: identity.firstName
* @param {T} model - This may also be a mongo update-doc, e.g.: {'identity.firstName': 'Franske'}
*/
get(field, model){
while(field && model){
if(model.hasOwnProperty(field)){
return model[field];// Simple case
}else{
// TODO this should be done better (e.g. it would not work if a doc is {'request.passenger': {'firstName': 'Joske'} })
let firstDot = field.indexOf('.');
if(firstDot<0) return;
let subField = field.slice(0, firstDot);
let remain = field.slice(firstDot+1);
if(model.hasOwnProperty(subField)){
model = model[subField];
field = remain;
}
}
}
}
}
+897
View File
@@ -0,0 +1,897 @@
// Collection class
import { Collection } from "mongodb";
import { TypedCursor } from "./typed-cursor";
import { TypeMapper } from "./type-mapper";
import { deepEquals } from "../../../utils";
/**
* MongoDb driver exposes a watch(...) function on the collection, this might be interesting to investigate further..
*
* @template T
*/
export class TypedCollection {
/** @type {BaseDatabase} */
#db;
get db() {
return this.#db;
}
/** @type {T} */
#model;
get model() {
return this.#model;
}
/** @type {string} */
#collectionName;
get collectionName() {
return this.#collectionName;
}
/** @type {TypeMapper} */
#typeMapper;
get typeMapper() {
return this.#typeMapper;
}
/** @type {Array<Collection~IndexDefinition>} */
#indexes;
get indexesToCreate() {
return this.#indexes;
}
/** @type {function} **/
#reviver;
get reviver() {
return this.#reviver;
}
/** @type {ICreateCollectionOptions} */
#options;
// Fields added on init
/** @type {Collection<T>} */
#collection;
get collection() {
return this.#collection;
}
/**
* @param {BaseDatabase} db
* @param {Object} opts
* @param {T} opts.model
* @param {string} opts.collectionName
* @param {Array<Collection~IndexDefinition>} [opts.indexes]
* @param {function} [opts.reviver]
* @param {ICreateCollectionOptions} [opts.options]
* @constructor
*/
constructor(
db, {
model,
collectionName,
indexes,
reviver,
options
} = {}) {
this.#db = db;
this.#collectionName = collectionName;
this.#model = model;
this.#typeMapper = new TypeMapper(model, { });
this.#indexes = indexes || [];
this.#reviver = reviver;
this.#options = options;
}
async init() {
this.#collection = await this.#db.ensureCollection(this.#collectionName, this.#options);
if (!this.#collection) {
throw new Error(`Collection "${this.#collectionName}" could not be ensured`);
}
// Indexes
const indexesToCreate = [];
const existingIndexes = await this.listIndexes().toArray().catch(err => []);
const indexesToDelete = new Set(existingIndexes.filter(x => x.name !== '_id_'));
for (let index of this.#indexes) {
// If this index does not exist yet
let matchingIndex = existingIndexes.find(existingIndex => deepEquals({
...existingIndex,
// unset
v: undefined,
name: undefined,
ns: undefined
}, {
...index,
// unset
v: undefined,
name: undefined,
ns: undefined
}));
if (!matchingIndex) {
// Index doesn't exist yet, queue for addition
indexesToCreate.push(index);
} else {
// Indexes already exists, make sure it doesn't get deleted
indexesToDelete.delete(matchingIndex);
}
}
// Delete indexes that are no longer needed
if (indexesToDelete.size) {
const indexesDeleteResult = await Promise.all(
Array.from(indexesToDelete).map(index => this.dropIndex(index.name))
);
// TODO Again process results..
}
// Create the indexes
if (indexesToCreate?.length) {
const indexesResult = await this.createIndexes(indexesToCreate);
// TODO properly process results..
}
// TODO Delete other indexes that are no longer desirable. (Need to be aware though when this happens)
}
async start() {
}
async disconnect() {
}
async findPaged(query, {page, pageSize, orderBy, ...options}) {
const paging = {
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
};
const limit = Number(pageSize) || 10;
const skip = ((Number(page) || 1) - 1) * limit
const sort = {};
if(orderBy) {
if(orderBy.startsWith("-")) {
sort[orderBy.substring(1)] = -1;
} else {
sort[orderBy] = 1;
}
}
const totalElements = await this.countDocuments(query);
return {
results: await this.find(
query,
{
limit,
skip,
sort,
...options
}
).toArray(),
paging: {
...paging,
total: totalElements,
totalPages: Math.ceil(totalElements / paging.pageSize)
},
orderBy
};
}
// Exposed functions (JSDocs copy pasted from mongodb-driver source, and filtered to non-deprecated stuff)
/**
* @param {object} [query={}] The cursor query object.
* @param {object} [options] Optional settings.
* @param {number} [options.limit=0] Sets the limit of documents returned in the query.
* @param {(array|object)} [options.sort] Set to sort the documents coming back from the query. Array of indexes, [['a', 1]] etc.
* @param {object} [options.projection] The fields to return in the query. Object of fields to either include or exclude (one of, not both), {'a':1, 'b': 1} **or** {'a': 0, 'b': 0}
* @param {number} [options.skip=0] Set to skip N documents ahead in your query (useful for pagination).
* @param {Object} [options.hint] Tell the query to use specific indexes in the query. Object of indexes to use, {'_id':1}
* @param {boolean} [options.explain=false] Explain the query instead of returning the data.
* @param {boolean} [options.snapshot=false] DEPRECATED: Snapshot query.
* @param {boolean} [options.timeout=false] Specify if the cursor can timeout.
* @param {boolean} [options.tailable=false] Specify if the cursor is tailable.
* @param {boolean} [options.awaitData=false] Specify if the cursor is a a tailable-await cursor. Requires `tailable` to be true
* @param {number} [options.batchSize=1000] Set the batchSize for the getMoreCommand when iterating over the query results.
* @param {boolean} [options.returnKey=false] Only return the index key.
* @param {number} [options.min] Set index bounds.
* @param {number} [options.max] Set index bounds.
* @param {boolean} [options.showDiskLoc=false] Show disk location of results.
* @param {string} [options.comment] You can put a $comment field on a query to make looking in the profiler logs simpler.
* @param {boolean} [options.raw=false] Return document results as raw BSON buffers.
* @param {boolean} [options.promoteLongs=true] Promotes Long values to number if they fit inside the 53 bits resolution.
* @param {boolean} [options.promoteValues=true] Promotes BSON values to native types where possible, set to false to only receive wrapper types.
* @param {boolean} [options.promoteBuffers=false] Promotes Binary BSON values to native Node Buffers.
* @param {(ReadPreference|string)} [options.readPreference] The preferred read preference (ReadPreference.PRIMARY, ReadPreference.PRIMARY_PREFERRED, ReadPreference.SECONDARY, ReadPreference.SECONDARY_PREFERRED, ReadPreference.NEAREST).
* @param {boolean} [options.partial=false] Specify if the cursor should return partial results when querying against a sharded system
* @param {number} [options.maxTimeMS] Number of milliseconds to wait before aborting the query.
* @param {number} [options.maxAwaitTimeMS] The maximum amount of time for the server to wait on new documents to satisfy a tailable cursor query. Requires `taiable` and `awaitData` to be true
* @param {boolean} [options.noCursorTimeout] The server normally times out idle cursors after an inactivity period (10 minutes) to prevent excess memory use. Set this option to prevent that.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {TypedCursor<T>}
*/
find(query, options) {
let cursor = this.#collection.find(this.#typeMapper.mapMongoQuery(query), options);
return new TypedCursor(cursor, this.#typeMapper, this.#reviver);
}
/**
* Fetches the first document that matches the query
*
* @param {object} query Query for find Operation
* @param {object} [options] Optional settings.
* @param {number} [options.limit=0] Sets the limit of documents returned in the query.
* @param {(array|object)} [options.sort] Set to sort the documents coming back from the query. Array of indexes, [['a', 1]] etc.
* @param {object} [options.projection] The fields to return in the query. Object of fields to include or exclude (not both), {'a':1}
* @param {object} [options.fields] **Deprecated** Use `options.projection` instead
* @param {number} [options.skip=0] Set to skip N documents ahead in your query (useful for pagination).
* @param {Object} [options.hint] Tell the query to use specific indexes in the query. Object of indexes to use, {'_id':1}
* @param {boolean} [options.explain=false] Explain the query instead of returning the data.
* @param {boolean} [options.snapshot=false] DEPRECATED: Snapshot query.
* @param {boolean} [options.timeout=false] Specify if the cursor can timeout.
* @param {boolean} [options.tailable=false] Specify if the cursor is tailable.
* @param {number} [options.batchSize=1] Set the batchSize for the getMoreCommand when iterating over the query results.
* @param {boolean} [options.returnKey=false] Only return the index key.
* @param {number} [options.maxScan] DEPRECATED: Limit the number of items to scan.
* @param {number} [options.min] Set index bounds.
* @param {number} [options.max] Set index bounds.
* @param {boolean} [options.showDiskLoc=false] Show disk location of results.
* @param {string} [options.comment] You can put a $comment field on a query to make looking in the profiler logs simpler.
* @param {boolean} [options.raw=false] Return document results as raw BSON buffers.
* @param {boolean} [options.promoteLongs=true] Promotes Long values to number if they fit inside the 53 bits resolution.
* @param {boolean} [options.promoteValues=true] Promotes BSON values to native types where possible, set to false to only receive wrapper types.
* @param {boolean} [options.promoteBuffers=false] Promotes Binary BSON values to native Node Buffers.
* @param {(ReadPreference|string)} [options.readPreference] The preferred read preference (ReadPreference.PRIMARY, ReadPreference.PRIMARY_PREFERRED, ReadPreference.SECONDARY, ReadPreference.SECONDARY_PREFERRED, ReadPreference.NEAREST).
* @param {boolean} [options.partial=false] Specify if the cursor should return partial results when querying against a sharded system
* @param {number} [options.maxTimeMS] Number of milliseconds to wait before aborting the query.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<T>} Returns the document if one is found
*/
async findOne(query, options) {
let result = await this.#collection.findOne(this.#typeMapper.mapMongoQuery(query), options);
return this.#reviver ? this.#reviver(result) : this.#typeMapper.mapToModel(result);
}
/**
* Find a document and delete it in one atomic operation. Requires a write lock for the duration of the operation.
*
* @method
* @param {object} query The Filter used to select the document to remove
* @param {object} [options] Optional settings.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {object} [options.projection] Limits the fields to return for all matching documents.
* @param {object} [options.sort] Determines which document the operation modifies if the query selects multiple documents.
* @param {number} [options.maxTimeMS] The maximum amount of time to allow the query to run.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<TypedCollection~findAndModifyWriteOpResult<T>>} Result of the operation
*/
async findOneAndDelete(query, options) {
let result = await this.#collection.findOneAndDelete(this.#typeMapper.mapMongoQuery(query), options);
if (result.ok === 1) {
result.value = this.#reviver ? this.#reviver(result.value) : this.#typeMapper.mapToModel(result.value);
}
return result;
}
/**
* Find a document and replace it in one atomic operation. Requires a write lock for the duration of the operation.
*
* @param {object} query The Filter used to select the document to replace
* @param {object} replacement The Document that replaces the matching document
* @param {object} [options] Optional settings.
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {number} [options.maxTimeMS] The maximum amount of time to allow the query to run.
* @param {object} [options.projection] Limits the fields to return for all matching documents.
* @param {object} [options.sort] Determines which document the operation modifies if the query selects multiple documents.
* @param {boolean} [options.upsert=false] Upsert the document if it does not exist.
* @param {boolean} [options.returnDocument='before'] When 'after', returns the updated document rather than the original. The default is 'before'.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<TypedCollection~findAndModifyWriteOpResult<T>>} Result of the operation
*/
async findOneAndReplace(query, replacement, options) {
let result = await this.#collection.findOneAndReplace(this.#typeMapper.mapMongoQuery(query), replacement, options);
return this.#typeMapper.mapToModel(result);
}
/**
* Find a document and update it in one atomic operation. Requires a write lock for the duration of the operation.
*
* @param {object} query The Filter used to select the document to update
* @param {object} update Update operations to be performed on the document
* @param {object} [options] Optional settings.
* @param {Array} [options.arrayFilters] optional list of array filters referenced in filtered positional operators
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {number} [options.maxTimeMS] The maximum amount of time to allow the query to run.
* @param {object} [options.projection] Limits the fields to return for all matching documents.
* @param {object} [options.sort] Determines which document the operation modifies if the query selects multiple documents.
* @param {boolean} [options.upsert=false] Upsert the document if it does not exist.
* @param {boolean} [options.returnDocument='before'] When 'after', returns the updated document rather than the original. The default is 'before'.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<T>} Result of the operation
*/
async findOneAndUpdate(query, update, options) {
const result = await this.#collection.findOneAndUpdate(this.#typeMapper.mapMongoQuery(query), update, options);
if (result && typeof (result) === 'object') {
return this.#reviver ? this.#reviver(result) : this.#typeMapper.mapToModel(result);
} else {
return result;
}
}
/**
* Delete multiple documents from a collection
*
* @param {object} filter The Filter used to select the documents to remove
* @param {object} [options] Optional settings.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<Collection~deleteWriteOpResult>} returns Promise if no callback passed
*/
async deleteMany(filter, options) {
return await this.#collection.deleteMany(this.#typeMapper.mapMongoQuery(filter), options);
}
/**
* Delete a document from a collection
*
* @param {object} filter The Filter used to select the document to remove
* @param {object} [options] Optional settings.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<DeleteResult>}
*/
async deleteOne(filter, options) {
return await this.#collection.deleteOne(this.#typeMapper.mapMongoQuery(filter), options);
}
/**
* Inserts an array of documents into MongoDB. If documents passed in do not contain the **_id** field,
* one will be added to each of the documents missing it by the driver, mutating the document. This behavior
* can be overridden by setting the **forceServerObjectId** flag.
*
* @param {object[]} docs Documents to insert.
* @param {object} [options] Optional settings.
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {boolean} [options.ordered=true] If true, when an insert fails, don't execute the remaining writes. If false, continue with remaining inserts when one fails.
* @param {boolean} [options.forceServerObjectId=false] Force server to assign _id values instead of driver.
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=true] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<Collection~insertWriteOpResult>} The command result
*/
async insertMany(docs, options) {
return await this.#collection.insertMany(this.typeMapper.mapToMongo(docs), options);
}
/**
* Inserts a single document into MongoDB. If documents passed in do not contain the **_id** field,
* one will be added to each of the documents missing it by the driver, mutating the document. This behavior
* can be overridden by setting the **forceServerObjectId** flag.
*
* @param {T} doc Document to insert.
* @param {object} [options] Optional settings.
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {boolean} [options.forceServerObjectId=false] Force server to assign _id values instead of driver.
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=true] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<InsertOneResult<T>>} The command result
*/
async insertOne(doc, options) {
return await this.#collection.insertOne(this.typeMapper.mapToMongo(doc), options);
}
/**
* Update multiple using a bulkwrite op
* @param {T[]} docs
* @returns {Promise<Collection~BulkWriteOpResult>}
*/
async update(docs) {
if (!docs) return;
if (!(docs instanceof Array)) return this.update([docs]);
let ops = [];
for (let doc of docs) {
if (!doc._id) {
throw new Error("Can't update a doc without knowing it's _id field!");
}
ops.push({
updateOne: {
filter: {
_id: doc._id,
},
update: {
$set: this.typeMapper.mapToMongo(doc)
},
upsert: false
}
});
}
let result = await this.collection.bulkWrite(ops, { ordered: false });
return result;
}
/**
* Update multiple documents in a collection
*
* @param {object} query The Filter used to select the documents to update
* @param {object} update The update operations to be applied to the documents
* @param {object} [options] Optional settings.
* @param {Array} [options.arrayFilters] optional list of array filters referenced in filtered positional operators
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {object} [options.hint] An optional hint for query optimization. See the {@link https://docs.mongodb.com/manual/reference/command/update/#update-command-hint|update command} reference for more information.
* @param {boolean} [options.upsert=false] When true, creates a new document if no document matches the query..
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<Collection~updateWriteOpResult>}
*/
async updateMany(query, update, options) {
return await this.#collection.updateMany(this.#typeMapper.mapMongoQuery(query), this.typeMapper.mapMongoUpdate(update), options);
}
/**
* Update a single document in a collection
*
* @param {object} query The Filter used to select the document to update
* @param {object} update The update operations to be applied to the document
* @param {object} [options] Optional settings.
* @param {Array} [options.arrayFilters] optional list of array filters referenced in filtered positional operators
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {object} [options.hint] An optional hint for query optimization. See the {@link https://docs.mongodb.com/manual/reference/command/update/#update-command-hint|update command} reference for more information.
* @param {boolean} [options.upsert=false] When true, creates a new document if no document matches the query..
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<Collection~updateWriteOpCallback>}
*/
async updateOne(query, update, options) {
return await this.#collection.updateOne(this.#typeMapper.mapMongoQuery(query), this.typeMapper.mapMongoUpdate(update), options);
}
/**
* Replace a document in a collection with another document
*
* @param {object} query The Filter used to select the document to replace
* @param {object} doc The Document that replaces the matching document
* @param {object} [options] Optional settings.
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {object} [options.hint] An optional hint for query optimization. See the {@link https://docs.mongodb.com/manual/reference/command/update/#update-command-hint|update command} reference for more information.
* @param {boolean} [options.upsert=false] When true, creates a new document if no document matches the query.
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<Collection~updateWriteOpResult>}
*/
async replaceOne(query, doc, options) {
return await this.#collection.replaceOne(this.#typeMapper.mapMongoQuery(query), this.typeMapper.mapToMongo(doc), options)
}
/**
* The distinct command returns a list of distinct values for the given key across a collection.
* @param {string} key Field of the document to find distinct values for.
* @param {object} [query] The query for filtering the set of documents to which we apply the distinct filter.
* @param {object} [options] Optional settings.
* @param {(ReadPreference|string)} [options.readPreference] The preferred read preference (ReadPreference.PRIMARY, ReadPreference.PRIMARY_PREFERRED, ReadPreference.SECONDARY, ReadPreference.SECONDARY_PREFERRED, ReadPreference.NEAREST).
* @param {number} [options.maxTimeMS] Number of milliseconds to wait before aborting the query.
* @param {object} [options.collation] Specify collation settings for operation. See {@link https://docs.mongodb.com/manual/reference/command/aggregate|aggregation documentation}.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>}
*/
async distinct(key, query, options) {
return await this.#collection.distinct(key, this.#typeMapper.mapMongoQuery(query), options);
}
/**
* Perform a bulkWrite operation without a fluent API
*
* Legal operation types are
*
* { insertOne: { document: { a: 1 } } }
*
* { updateOne: { filter: {a:2}, update: {$set: {a:2}}, upsert:true } }
*
* { updateMany: { filter: {a:2}, update: {$set: {a:2}}, upsert:true } }
*
* { updateMany: { filter: {}, update: {$set: {"a.$[i].x": 5}}, arrayFilters: [{ "i.x": 5 }]} }
*
* { deleteOne: { filter: {c:1} } }
*
* { deleteMany: { filter: {c:1} } }
*
* { replaceOne: { filter: {c:3}, replacement: {c:4}, upsert:true}}
*
* If documents passed in do not contain the **_id** field,
* one will be added to each of the documents missing it by the driver, mutating the document. This behavior
* can be overridden by setting the **forceServerObjectId** flag.
*
* @method
* @param {object[]} operations Bulk operations to perform.
* @param {object} [options] Optional settings.
* @param {boolean} [options.ordered=true] Execute write operation in ordered or unordered fashion.
* @param {boolean} [options.bypassDocumentValidation=false] Allow driver to bypass schema validation in MongoDB 3.2 or higher.
* @param {object[]} [options.arrayFilters] Determines which array elements to modify for update operation in MongoDB 3.6 or higher.
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.checkKeys=false] If true, will throw if bson documents start with `$` or include a `.` in any key value
* @param {boolean} [options.serializeFunctions=false] Serialize functions on any object.
* @param {boolean} [options.ignoreUndefined=false] Specify if the BSON serializer should ignore undefined fields.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise}
*/
async bulkWrite(operations, options) {
return await this.#collection.bulkWrite(operations, options);
}
/**
* Gets the number of documents matching the filter.
* For a fast count of the total documents in a collection see {@link Collection#estimatedDocumentCount estimatedDocumentCount}.
* @param {object} [query] the query for the count
* @param {object} [options] Optional settings.
* @param {object} [options.collation] Specifies a collation.
* @param {string|object} [options.hint] The index to use.
* @param {number} [options.limit] The maximum number of document to count.
* @param {number} [options.maxTimeMS] The maximum amount of time to allow the operation to run.
* @param {number} [options.skip] The number of documents to skip before counting.
* @return {Promise<number>} returns Promise if no callback passed.
*/
async countDocuments(query, options) {
return await this.#collection.countDocuments(this.#typeMapper.mapMongoQuery(query), options);
}
/**
* Gets an estimate of the count of documents in a collection using collection metadata.
*
* @param {object} [options] Optional settings.
* @param {number} [options.maxTimeMS] The maximum amount of time to allow the operation to run.
* @return {Promise<number>} returns Promise if no callback passed.
*/
async estimatedDocumentCount(options) {
return await this.#collection.estimatedDocumentCount(options);
}
/**
* Get all the collection statistics.
*
* @param {object} [options] Optional settings.
* @param {number} [options.scale] Divide the returned sizes by scale value.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>}
*/
async stats(options) {
return await this.#collection.stats(options);
}
/**
* Creates an index on the db and collection collection.
* @method
* @param {(string|array|object)} fieldOrSpec Defines the index.
* @param {object} [options] Optional settings.
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {boolean} [options.unique=false] Creates an unique index.
* @param {boolean} [options.sparse=false] Creates a sparse index.
* @param {boolean} [options.background=false] Creates the index in the background, yielding whenever possible.
* @param {boolean} [options.dropDups=false] A unique index cannot be created on a key that has pre-existing duplicate values. If you would like to create the index anyway, keeping the first document the database indexes and deleting all subsequent documents that have duplicate value
* @param {number} [options.min] For geospatial indexes set the lower bound for the co-ordinates.
* @param {number} [options.max] For geospatial indexes set the high bound for the co-ordinates.
* @param {number} [options.v] Specify the format version of the indexes.
* @param {number} [options.expireAfterSeconds] Allows you to expire data on indexes applied to a data (MongoDB 2.2 or higher)
* @param {string} [options.name] Override the autogenerated index name (useful if the resulting name is larger than 128 bytes)
* @param {object} [options.partialFilterExpression] Creates a partial index based on the given filter object (MongoDB 3.2 or higher)
* @param {object} [options.collation] Specify collation (MongoDB 3.4 or higher) settings for update operation (see 3.4 documentation for available fields).
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} result The result object if the command was executed successfully.
* @example
* const collection = client.db('foo').collection('bar');
*
* await collection.createIndex({ a: 1, b: -1 });
*
* // Alternate syntax for { c: 1, d: -1 } that ensures order of indexes
* await collection.createIndex([ [c, 1], [d, -1] ]);
*
* // Equivalent to { e: 1 }
* await collection.createIndex('e');
*
* // Equivalent to { f: 1, g: 1 }
* await collection.createIndex(['f', 'g'])
*
* // Equivalent to { h: 1, i: -1 }
* await collection.createIndex([ { h: 1 }, { i: -1 } ]);
*
* // Equivalent to { j: 1, k: -1, l: 2d }
* await collection.createIndex(['j', ['k', -1], { l: '2d' }])
*/
async createIndex(fieldOrSpec, options) {
return await this.#collection.createIndex(fieldOrSpec, options);
}
/**
* Creates multiple indexes in the collection, this method is only supported for
* MongoDB 2.6 or higher. Earlier version of MongoDB will throw a command not supported
* error.
*
* **Note**: Unlike {@link Collection#createIndex createIndex}, this function takes in raw index specifications.
* Index specifications are defined {@link http://docs.mongodb.org/manual/reference/command/createIndexes/ here}.
*
* @method
* @param {Collection~IndexDefinition[]} indexSpecs An array of index specifications to be created
* @param {Object} [options] Optional settings
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} result The result object if the command was executed successfully.
* @example
* const collection = client.db('foo').collection('bar');
* await collection.createIndexes([
* // Simple index on field fizz
* {
* key: { fizz: 1 },
* }
* // wildcard index
* {
* key: { '$**': 1 }
* },
* // named index on darmok and jalad
* {
* key: { darmok: 1, jalad: -1 }
* name: 'tanagra'
* }
* ]);
*/
async createIndexes(indexSpecs, options) {
return await this.#collection.createIndexes(indexSpecs, options);
}
/**
* Drops an index from this collection.
*
* @param {string} indexName Name of the index to drop.
* @param {object} [options] Optional settings.
* @param {(number|string)} [options.w] The write concern.
* @param {number} [options.wtimeout] The write concern timeout.
* @param {boolean} [options.j=false] Specify a journal write concern.
* @param {ClientSession} [options.session] optional session to use for this operation
* @param {number} [options.maxTimeMS] Number of milliseconds to wait before aborting the query.
* @return {Promise<object>} The command result
*/
async dropIndex(indexName, options) {
return await this.#collection.dropIndex(indexName, options);
}
/**
* Drops all indexes from this collection.
* @method
* @param {Object} [options] Optional settings
* @param {ClientSession} [options.session] optional session to use for this operation
* @param {number} [options.maxTimeMS] Number of milliseconds to wait before aborting the query.
* @return {Promise<object>} The command result
*/
async dropIndexes(options) {
return await this.#collection.dropIndexes(options);
}
/**
* Retrieve all the indexes on the collection.
*
* @param {Object} [options] Optional settings
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} The command result
*/
async indexes(options) {
return await this.#collection.indexes(options);
}
/**
* Checks if one or more indexes exist on the collection, fails on first non-existing index
*
* @param {(string|array)} indexes One or more index names to check.
* @param {Object} [options] Optional settings
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} The command result
*/
async indexExists(indexes, options) {
return await this.#collection.indexExists(indexes, options);
}
/**
* Retrieves this collections index info.
*
* @param {object} [options] Optional settings.
* @param {boolean} [options.full=false] Returns the full raw index information.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} The command result
*/
async indexInformation(options) {
return await this.#collection.indexInformation(options);
}
/**
* Get the list of all indexes information for the collection.
*
* @param {object} [options] Optional settings.
* @param {number} [options.batchSize=1000] The batchSize for the returned command cursor or if pre 2.8 the systems batch collection
* @param {(ReadPreference|string)} [options.readPreference] The preferred read preference (ReadPreference.PRIMARY, ReadPreference.PRIMARY_PREFERRED, ReadPreference.SECONDARY, ReadPreference.SECONDARY_PREFERRED, ReadPreference.NEAREST).
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {CommandCursor}
*/
listIndexes(options) {
return this.#collection.listIndexes(options);
}
/**
* Reindex all indexes on the collection
* Warning: reIndex is a blocking operation (indexes are rebuilt in the foreground) and will be slow for large collections.
*
* @param {Object} [options] Optional settings
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} command result
*/
async reIndex(options) {
return await this.#collection.reIndex(options)
}
/**
* Returns the options of the collection.
*
* @param {Object} [options] Optional settings
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} command result
*/
async options(opts) {
return await this.#collection.options(opts)
}
/**
* Rename the collection.
*
* @param {string} newName New name of of the collection.
* @param {object} [options] Optional settings.
* @param {boolean} [options.dropTarget=false] Drop the target name collection if it previously exists.
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>} command result
*/
async rename(newName, options) {
return await this.#collection.rename(newName, options)
}
/**
* Drop the collection from the database, removing it permanently. New accesses will create a new collection.
*
* @param {object} [options] Optional settings.
* @param {WriteConcern} [options.writeConcern] A full WriteConcern object
* @param {(number|string)} [options.w] The write concern
* @param {number} [options.wtimeout] The write concern timeout
* @param {boolean} [options.j] The journal write concern
* @param {ClientSession} [options.session] optional session to use for this operation
* @return {Promise<object>}
*/
async drop(options) {
return await this.#collection.drop(options);
}
/**
* // TODO!
*
* @param {object} filter
* @param {number} skip
* @param {number} limit
* @param {object} sort
* @param {string} language
* @param {string} organizationId
* @param {boolean} count
* @returns {{results: T[], count: number}}
*/
async query({ filter, skip, limit, sort, count, language, organizationId }) {
if (typeof (sort) === 'string') {
sort = sort.split(',').map(t => t.trim()).filter(x => x)
.map(s => s[ 0 ] === '-' ? [s.slice(1), -1] : [s, 1]);
} else if (Object.entries(sort).length === 0) {
sort = undefined;
}
if (skip && !(typeof (skip) === 'number')) {
skip = Number.parseInt(skip);
}
if (limit && !(typeof (limit) === 'number')) {
limit = Number.parseInt(limit);
} else if (limit === undefined) {
limit = 100; // default limit;
}
let hasFilter = filter && Object.entries(filter).length > 0;
let match = hasFilter && filter;
if (count) {
let rawResults = await this.#collection.aggregate([
match ? { $match: match } : null,
{
$facet: {
results: [
sort ? { $sort: sort } : null,
skip ? { $skip: skip } : null,
limit ? { $limit: limit } : null
].filter(t => t),
count: [{ $count: 'count' }]
}
}
].filter(x => x));
return {
results: rawResults.map(r => this.typeMapper.mapToModel(r)),
count: rawResults.count.count
};
} else {
let getOp = this.find(match ?? {});
if (sort) getOp = getOp.sort(sort);
if (skip > 0) getOp = getOp.skip(skip);
if (limit > 0) getOp = getOp.limit(limit);
let rawResults = await getOp.toArray();
return {
results: rawResults
};
}
}
/**
* // TODO!
*
* @param {object} query
* @returns {Array}
*/
async aggregate(query) {
const rawResults = await this.#collection.aggregate(query);
return rawResults.toArray();
}
}
+196
View File
@@ -0,0 +1,196 @@
import { Cursor } from "mongodb";
/**
* @template T
* @augments {Cursor}
* @class
*/
export class TypedCursor {
/**
* @param {Cursor} cursor
* @param {TypeMapper} typeMapper
* @param {function} reviver
*/
constructor(cursor, typeMapper, reviver) {
this.#cursor = cursor;
this.#typeMapper = typeMapper;
this.#reviver = reviver;
}
/** @type {Cursor} */
#cursor;
/** @type {TypeMapper} */
#typeMapper;
/** @type {function} */
#reviver;
/**
* Set the cursor query
* @method
* @param {object} filter The filter object used for the cursor.
* @return {TypedCursor<T>}
*/
filter(filter) {
this.#cursor.filter(filter);
return this;
}
/**
* Set the cursor hint
* @method
* @param {object} hint If specified, then the query system will only consider plans using the hinted index.
* @return {TypedCursor<T>}
*/
hint(hint) {
this.#cursor.hint(hint);
return this;
}
/**
* Set the skip for the cursor.
* @param {number} value The skip for the cursor query.
* @throws {MongoError}
* @return {TypedCursor<T>}
*/
skip(value) {
this.#cursor.skip(value);
return this;
}
/**
* Set the limit for the cursor.
* @param {number} value The limit for the cursor query.
* @throws {MongoError}
* @return {TypedCursor<T>}
*/
limit(value) {
this.#cursor.limit(value);
return this;
}
/**
* Sets the sort order of the cursor query.
* @param {(string|array|object)} keyOrList The key or keys set for the sort.
* @param {number} [direction] The direction of the sorting (1 or -1).
* @throws {MongoError}
* @return {TypedCursor<T>}
*/
sort(keyOrList, direction) {
this.#cursor.sort(keyOrList, direction);
return this;
}
/**
* Set the ReadPreference for the cursor.
* @param {(string|ReadPreference)} readPreference The new read preference for the cursor.
* @throws {MongoError}
* @return {TypedCursor<T>}
*/
setReadPreference(readPreference) {
this.#cursor.setReadPreference(readPreference);
return this;
}
/**
* Set the batch size for the cursor.
* @method
* @param {number} value The number of documents to return per batch. See {@link https://docs.mongodb.com/manual/reference/command/find/|find command documentation}.
* @return {TypedCursor<T>}
*/
batchSize(value) {
this.#cursor.batchSize(value);
return this;
}
/**
* Resets the cursor
*/
rewind(){
this.#cursor.rewind();
return this;
}
/**
* Returns an array of documents. The caller is responsible for making sure that there
* is enough memory to store the results. Note that the array only contains partial
* results when this cursor had been previously accessed. In that case,
* cursor.rewind() can be used to reset the cursor.
* @throws {MongoError}
* @return {Promise<T[]>}
*/
async toArray() {
let results = await this.#cursor.toArray();
return this.#reviver ? this.#reviver(results) : this.#typeMapper.mapToModel(results);
}
/**
* @return {Promise<number>}
*/
async count() {
return await this.#cursor.count();
}
/**
* Get the next available document from the cursor, returns null if no more documents are available.
* @throws {MongoError}
* @return {Promise<T>}
*/
async next() {
let value = await this.#cursor.next();
if (value && typeof (value) === 'object') {
return this.#reviver ? this.#reviver(value) : this.#typeMapper.mapToModel(value);
} else {
return value;
}
}
/**
* Check if there is any document still available in the cursor
* @method
* @throws {MongoError}
* @return {Promise<boolean>}
*/
async hasNext() {
return await this.#cursor.hasNext();
}
/**
* Execute the explain for the cursor
* @return {Promise<object>}
*/
async explain() {
return await this.#cursor.explain();
}
/**
* Iterates over all the documents for this cursor using the iterator, callback pattern.
* TODO refine?
* @param {Cursor~iteratorCallback} iterator The iteration callback.
* @throws {MongoError}
* @return {Promise}
*/
async forEach(iterator) {
return await this.#cursor.forEach(iterator);
}
/**
* Close the cursor, sending a KillCursor command and emitting close.
* @param {object} [options] Optional settings.
* @param {boolean} [options.skipKillCursors] Bypass calling killCursors when closing the cursor.
* @return {Promise}
*/
async close(options) {
return await this.#cursor.close(options);
}
/**
* Is the cursor closed
* @return {boolean}
*/
get isClosed(){
return this.#cursor.isClosed();
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./jwt/index.js"
+121
View File
@@ -0,0 +1,121 @@
import {db} from "./db/index.js";
import {ApiError, BadRequestError, NotFoundError} from "../models/errors/index.js";
import {Instance} from "../models/portfolio/instance.js";
import cleanForId from "../utils/clean-for-id.js";
import {Photo} from "../models/portfolio/photo.js";
import {minio} from "./minio.js";
class InstancesCache{
/** @type {{[string]: string}} **/
instances = {};
}
const cache = new InstancesCache();
class InstancesService {
async findByUrlCached(url){
if (!cache[url]){
cache.instances[url] = await db.instances.findOne({urls: url});
}
return cache.instances[url];
}
async getInstance(instanceId) {
return await db.instances.find({_id: instanceId}).toArray()
}
async getInstances(availableInstances) {
return await db.instances.find({
instanceId: { $in: availableInstances }
}).toArray()
}
async updateInstance(instanceToUpdate) {
const instance = await db.instances.findOne({
instanceId: instanceToUpdate.instanceId
});
if (!instance) {
throw new NotFoundError("Instance not found");
}
instance.title = instanceToUpdate.title;
instance.subtitle = instanceToUpdate.subtitle;
instance.urls = instanceToUpdate.urls;
try {
await db.instances.update(instance);
console.log("Instance updated", instance)
return instance;
} catch(err) {
throw new ApiError(err);
}
}
/**
* @param {Instance} instance
* @return {Promise<Instance>}
*/
async createInstance(instance) {
instance.instanceId = cleanForId(instance.title);
let newInstance = new Instance({
...instance,
});
try {
const result = await db.instances.insertOne(newInstance, {
checkKeys: true,
bypassDocumentValidation: false
});
if (result.acknowledged && result.insertedId) {
return await db.instances.findOne({instanceId: newInstance.instanceId});
}
throw new BadRequestError("The instance could not be created.")
} catch (err) {
if (err instanceof BadRequestError) throw err;
console.error(err);
throw new BadRequestError(err.message);
}
}
/**
* @param {string} instanceId
* @param {FileUploadInfo} file
* @return {Promise<Instance>}
*/
async setCoverPhoto(instanceId, file) {
const instance = await db.instances.findOne({instanceId});
if (!instance) {
throw new NotFoundError("Instance not found");
}
const split = file.mimetype.split("/");
if(["image/*"].filter(a => a === file.mimetype || a === `${split[0]}/*`).length === 0){
throw new BadRequestError(`The following file has an incorrect mimetype ${file.mimetype}]: ${file.fieldname}`);
}
instance.coverPhoto = new Photo({
photoId: cleanForId(file.originalname),
fileName: file.originalname,
mimeType: file.mimetype,
name: file.originalname.substring(0, file.originalname.lastIndexOf("."))
})
// Upload file
/** @type {{photo: Photo, buffer: Buffer}[]} **/
instance.coverPhoto = new Photo({
photoId: cleanForId(file.originalname),
fileName: file.originalname,
mimeType: file.mimetype,
name: file.originalname.substring(0, file.originalname.lastIndexOf("."))
});
instance.coverPhoto.s3Path = await minio.uploadFile(file.buffer, {
path: instance.instanceId,
name: instance.coverPhoto.fileName,
type: instance.coverPhoto.mimeType
});
await db.instances.update(instance);
return instance;
}
}
const instance = new InstancesService();
export {instance as instances};
+11
View File
@@ -0,0 +1,11 @@
class JwtService {
decode(token) {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const buff = new Buffer(base64, 'base64');
const payload = buff.toString('ascii');
return JSON.parse(payload);
}
}
const instance = new JwtService();
export { instance as JwtService };
+74
View File
@@ -0,0 +1,74 @@
import {config} from "../config.js";
import {Client} from "minio";
class MinioService {
/** @type {Client} */
minioClient;
constructor() {
this.minioClient = new Client({
endPoint: config.minio.endpoint,
port: config.minio.port,
useSSL: config.minio.useSSL,
accessKey: config.minio.accesskey,
secretKey: config.minio.secret
});
this.#ensureBucket();
}
/**
* @param {Buffer} buffer
* @param {{path: string, name: string, type: string}} file
* @returns {Promise<string>}
*/
async uploadFile(buffer, {path, name, type}) {
const metaData = {
'Content-Type': type,
};
const filePath = `${path}/${name}`;
const minioResult = await this.minioClient.putObject(config.minio.bucket, filePath, buffer, metaData);
if (minioResult.etag) {
return filePath;
}
throw new Error(minioResult);
}
/**
* @param {string} filePath
* @returns {Promise<IncomingMessage>}
*/
async downloadFile(filePath) {
return await this.minioClient.getObject(config.minio.bucket, filePath);
}
/**
* @param {string} s3Path
* @returns {Promise<*>}
*/
async deleteFile(s3Path) {
await this.minioClient.removeObject(config.minio.bucket, s3Path);
}
/**
* @returns {Promise<void>}
*/
async #ensureBucket() {
try {
const exists = await this.minioClient.bucketExists(config.minio.bucket);
if (!exists) {
await this.minioClient.makeBucket(config.minio.bucket);
}
// Don't enable versioning, you can't delete the bucket anymore
// const isVersioned = await this.minioClient.getBucketVersioning(config.minio.bucket);
// if (isVersioned?.Status !== "Enabled") {
// await this.minioClient.setBucketVersioning(config.minio.bucket, {Status: "Enabled"})
// }
} catch (err) {
console.error(err);
}
}
}
const instance = new MinioService();
export {instance as minio};
+32
View File
@@ -0,0 +1,32 @@
import sharp from "sharp";
class ThumbnailerService {
async fromStream(source, resizeOptions, fit) {
const imageBuffer = await this.#streamToBuffer(source);
// const dimensions = this.getDimensions(imageBuffer, percentage, {width, height});
return sharp(imageBuffer)
.resize({
...resizeOptions,
fit: fit || 'contain'
})
.withMetadata()
.png()
.toBuffer();
}
#streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const buffers = [];
stream.on("data", function (data) {
buffers.push(data);
});
stream.on("end", function () {
resolve(Buffer.concat(buffers));
})
})
}
}
const instance = new ThumbnailerService();
export {instance as thumbnailer};
+3
View File
@@ -0,0 +1,3 @@
export default function cleanForId(valueToClean) {
return valueToClean.toLowerCase().replace(/[\s\\+=/.\[\]{})(*&^%$#@!]/g, '-');
}
+21
View File
@@ -0,0 +1,21 @@
// Return a shallow copy of an object
export function clone(o) {
let cloned, key;
if (Array.isArray(o)) {
return o.slice();
// typeof null is "object", but we want to copy it as null
} if (o === null) {
return o;
} else if (typeof o === "object") {
cloned = {};
for(key in o) {
if (Object.hasOwnProperty.call(o, key)) {
cloned[key] = o[key];
}
}
return cloned;
} else {
return o;
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Compares two objects with nested properties for equal enumerable key/value-pairs, the ordering of properties do not matter
* (Non-enumerable properties are not compared);
*
* @param {Object} objA
* @param {Object} objB
*/
export function deepEquals(objA, objB){
if((objA && !objB) || (!objA && objB)) return false;
let allKeys = new Set();
for(let key in objA) allKeys.add(key);
for(let key in objB) allKeys.add(key);
for(let key of allKeys){
let valA = objA[key], valB = objB[key];
if(valA!==valB){
if(typeof(valA)===typeof(valB) && typeof(valA)==='object'){
if(!deepEquals(valA,valB)) return false;
}else{
return false;
}
}
}
return true;// All good
}
+1
View File
@@ -0,0 +1 @@
export * from "./deep-equals.js";
+433
View File
@@ -0,0 +1,433 @@
/* @preserve
* JSONPatch.js
*
* A Dharmafly project written by Thomas Parslow
* <tom@almostobsolete.net> and released with the kind permission of
* NetDev.
*
* Copyright 2011-2013 Thomas Parslow. All rights reserved.
* Permission is hereby granted,y free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*
* Implements the JSON Patch IETF RFC 6902 as specified at:
*
* http://tools.ietf.org/html/rfc6902
*
* Also implements the JSON Pointer IETF RFC 6901 as specified at:
*
* http://tools.ietf.org/html/rfc6901
*
*/
/* Public: A class representing a JSON Pointer. A JSON Pointer is
* used to point to a specific sub-item within a JSON document.
*
* Example (node.js);
*
* jsonpatch = require('jsonpatch');
* var pointer = new jsonpatch.JSONPointer('/path/to/item');
* var item = pointer.follow(doc)
*
*/
import {InvalidPatch, PatchApplyError} from "../models/errors/invalid-patch.js";
import {clone} from "./clone.js";
import {deepEquals} from "./deep-equals.js";
class JSONPointer {
constructor(pathStr) {
let i,split,path=[];
// Split up the path
split = pathStr.split('/');
if ('' !== split[0]) {
throw new InvalidPatch('JSONPointer must start with a slash (or be an empty string)!');
}
for (i = 1; i < split.length; i++) {
path[i-1] = split[i].replace(/~1/g,'/').replace(/~0/g,'~');
}
this.path = path;
this.length = path.length;
}
/* Private: Get a segment of the pointer given a current doc
* context.
*/
_get_segment = function (index, node) {
let segment = this.path[index];
if(Array.isArray(node)) {
if ('-' === segment) {
segment = node.length;
} else {
// Must be a non-negative integer in base-10 without leading zeros
if (!segment.match(/^0$|^[1-9][0-9]*$/)) {
throw new PatchApplyError('Expected a number to segment an array');
}
segment = parseInt(segment,10);
}
}
return segment;
}
/* Private: Follow the pointer to its penultimate segment then call
* the handler with the current doc and the last key (converted to
* an int if the current doc is an array). The handler is expected to
* return a new copy of the penultimate part.
*
* doc - The document to search within
* handler - The callback function to handle the last part
*
* Returns the result of calling the handler
*/
_action = function (doc, handler, mutate) {
let that = this;
function follow_pointer(node, index) {
let segment, subnode;
if (!mutate) {
node = clone(node);
}
segment = that._get_segment(index, node);
// Is this the last segment?
if (index === that.path.length-1) {
node = handler(node, segment);
} else {
// Make sure we can follow the segment
if (Array.isArray(node)) {
if (node.length <= segment) {
throw new PatchApplyError('Path not found in document');
}
} else if (typeof node === "object") {
if (!Object.hasOwnProperty.call(node, segment)) {
throw new PatchApplyError('Path not found in document');
}
} else {
throw new PatchApplyError('Path not found in document');
}
subnode = follow_pointer(node[segment], index+1);
if (!mutate) {
node[segment] = subnode;
}
}
return node;
}
return follow_pointer(doc, 0);
}
/* Public: Takes a JSON document and a value and adds the value into
* the doc at the position pointed to. If the position pointed to is
* in an array then the existing element at that position (if any)
* and all that follow it have their position incremented to make
* room. It is an error to add to a parent object that doesn't exist
* or to try to replace an existing value in an object.
*
* doc - The document to operate against. Will be mutated so should
* not be reused after the call.
* value - The value to insert at the position pointed to
*
* Examples
*
* var doc = new JSONPointer("/obj/new").add({obj: {old: "hello"}}, "world");
* // doc now equals {obj: {old: "hello", new: "world"}}
*
* Returns the updated doc (the value passed in may also have been mutated)
*/
add(doc, value, mutate) {
// Special case for a pointer to the root
if (0 === this.length) {
return value;
}
return this._action(doc, function (node, lastSegment) {
if (Array.isArray(node)) {
if (lastSegment > node.length) {
throw new PatchApplyError('Add operation must not attempt to create a sparse array!');
}
node.splice(lastSegment, 0, value);
} else {
node[lastSegment] = value;
}
return node;
}, mutate);
}
/* Public: Takes a JSON document and removes the value pointed to.
* It is an error to attempt to remove a value that doesn't exist.
*
* doc - The document to operate against. May be mutated so should
* not be reused after the call.
*
* Examples
*
* var doc = new JSONPointer("/obj/old").add({obj: {old: "hello"}});
* // doc now equals {obj: {}}
*
* Returns the updated doc (the value passed in may also have been mutated)
*/
remove(doc, mutate) {
// Special case for a pointer to the root
if (0 === this.length) {
// Removing the root makes the whole value undefined.
// NOTE: Should it be an error to remove the root if it is
// ALREADY undefined? I'm not sure...
return undefined;
}
return this._action(doc, function (node, lastSegment) {
if (!Object.hasOwnProperty.call(node,lastSegment)) {
throw new PatchApplyError('Remove operation must point to an existing value!');
}
if (Array.isArray(node)) {
node.splice(lastSegment, 1);
} else {
delete node[lastSegment];
}
return node;
}, mutate);
}
/* Public: Semantically equivalent to a remove followed by an add
* except when the pointer points to the root element in which case
* the whole document is replaced.
*
* doc - The document to operate against. May be mutated so should
* not be reused after the call.
*
* Examples
*
* var doc = new JSONPointer("/obj/old").replace({obj: {old: "hello"}}, "world");
* // doc now equals {obj: {old: "world"}}
*
* Returns the updated doc (the value passed in may also have been mutated)
*/
replace(doc, value, mutate) {
// Special case for a pointer to the root
if (0 === this.length) {
return value;
}
return this._action(doc, function (node, lastSegment) {
if (!Object.hasOwnProperty.call(node,lastSegment)) {
throw new PatchApplyError('Replace operation must point to an existing value!');
}
if (Array.isArray(node)) {
node.splice(lastSegment, 1, value);
} else {
node[lastSegment] = value;
}
return node;
}, mutate);
}
/* Public: Returns the value pointed to by the pointer in the given doc.
*
* doc - The document to operate against.
*
* Examples
*
* var value = new JSONPointer("/obj/value").get({obj: {value: "hello"}});
* // value now equals 'hello'
*
* Returns the value
*/
get(doc) {
let value;
if (0 === this.length) {
return doc;
}
this._action(doc, function (node, lastSegment) {
if (!Object.hasOwnProperty.call(node,lastSegment)) {
throw new PatchApplyError('Path not found in document');
}
value = node[lastSegment];
return node;
}, true);
return value;
}
/* Public: returns true if this pointer points to a child of the
* other pointer given. Returns true if both point to the same place.
*
* otherPointer - Another JSONPointer object
*
* Examples
*
* var pointer1 = new JSONPointer('/animals/mammals/cats/holly');
* var pointer2 = new JSONPointer('/animals/mammals/cats');
* var isChild = pointer1.subsetOf(pointer2);
*
* Returns a boolean
*/
subsetOf(otherPointer) {
if (this.length <= otherPointer.length) {
return false;
}
for (let i = 0; i < otherPointer.length; i++) {
if (otherPointer.path[i] !== this.path[i]) {
return false;
}
}
return true;
}
}
/* Public: A class representing a patch.
*
* patch - The patch as an array or as a JSON string (containing an
* array)
* mutate - Indicates that input documents should be mutated
* (default is for the input to be unaffected.) This will
* not work correctly if the patch replaces the root of
* the document.
*/
class JSONPatch {
constructor(patch, mutate) {
this._compile(patch, mutate);
}
/* Public: Apply the patch to a document and returns the patched
* document.
*
* doc - The document to which the patch should be applied.
*
* Returns the patched document
*/
apply(doc) {
let i;
for(i = 0; i < this.compiledOps.length; i++) {
doc = this.compiledOps[i](doc);
}
return doc;
}
_operationRequired = {
add: ['value'],
replace: ['value'],
test: ['value'],
remove: [],
move: ['from'],
copy: ['from']
};
_compile(patch, mutate) {
let i, _this = this;
this.compiledOps = [];
if ('string' === typeof patch) {
patch = JSON.parse(patch);
}
if(!Array.isArray(patch)) {
throw new InvalidPatch('Patch must be an array of operations');
}
for(i = 0; i < patch.length; i++) {
let compiled = this.compileOperation(patch[i], mutate);
_this.compiledOps.push(compiled);
}
}
validateOp(operation) {
let i, required;
if (!operation.op) {
throw new InvalidPatch('Operation missing!');
}
if (!this._operationRequired.hasOwnProperty(operation.op)) {
throw new InvalidPatch('Invalid operation!');
}
if (!('path' in operation)) {
throw new InvalidPatch('Path missing!');
}
required = this._operationRequired[operation.op];
// Check that all required keys are present
for(i = 0; i < required.length; i++) {
if(!(required[i] in operation)) {
throw new InvalidPatch(operation.op + ' must have key ' + required[i]);
}
}
}
compileOperation(operation, mutate) {
this.validateOp(operation);
let op = operation.op;
let path = new JSONPointer(operation.path);
let value = operation.value;
let from = operation.from ? new JSONPointer(operation.from) : null;
switch (op) {
case 'add':
return function (doc) {
return path.add(doc, value, mutate);
};
case 'remove':
return function (doc) {
return path.remove(doc, mutate);
};
case 'replace':
return function (doc) {
return path.replace(doc, value, mutate);
};
case 'move':
// Check that destination isn't inside the source
if (path.subsetOf(from)) {
throw new InvalidPatch('destination must not be a child of source');
}
return function (doc) {
let value = from.get(doc);
let intermediate = from.remove(doc, mutate);
return path.add(intermediate, value, mutate);
};
case 'copy':
return function (doc) {
let value = from.get(doc);
return path.add(doc, value, mutate);
};
case 'test':
return function (doc) {
if (!deepEquals(path.get(doc), value)) {
throw new PatchApplyError("Test operation failed. Value did not match.");
}
return doc;
};
}
}
}
class JsonPatch {
/* Public: Shortcut to apply a patch the document without having to
* create a patch object first. Returns the patched document. Does
* not damage the original document, but will reuse parts of its
* structure in the new one.
*
* doc - The target document to which the patch should be applied.
* patch - A JSON Patch document specifying the changes to the
* target documentment
*
* Example (node.js)
*
* jsonpatch = require('jsonpatch');
* doc = JSON.parse(sourceJSON);
* doc = jsonpatch.apply_patch(doc, thepatch);
* destJSON = JSON.stringify(doc);
*
* Returns the patched document
*/
applyPatch = function (doc, patch) {
return (new JSONPatch(patch)).apply(doc);
};
}
const instance = new JsonPatch();
export { instance as jsonPatch };