Initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/dist
|
||||
Executable
+10
@@ -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"]
|
||||
Generated
+3553
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
]
|
||||
};
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#/bin/bash
|
||||
|
||||
docker build --tag photos-server .
|
||||
Executable
+6
@@ -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
|
||||
Executable
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+154
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+110
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
export * from "./authorize";
|
||||
export * from "./endpoint";
|
||||
export * from "./router.js";
|
||||
export * from "./context";
|
||||
Executable
+104
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./handle-authorization.js";
|
||||
export * from "./has-role.js";
|
||||
Executable
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+140
@@ -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();
|
||||
}
|
||||
Executable
+39
@@ -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...
|
||||
}
|
||||
}
|
||||
Executable
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
import { HrTimer } from "./hr-timer.js";
|
||||
import {KoaRequestContext} from "../../api/api-routing";
|
||||
|
||||
const methodAnsis = {
|
||||
'GET': '[32m',
|
||||
'POST': '[94m',
|
||||
'PATCH': '[96m',
|
||||
'PUT': '[33m',
|
||||
'DELETE': '[31m',
|
||||
};
|
||||
const statusAnsis = {
|
||||
200: '[32m',
|
||||
400: '[94m',
|
||||
401: '[31m',
|
||||
403: '[31m',
|
||||
404: '[96m',
|
||||
500: '[31m',
|
||||
501: '[94m',
|
||||
};
|
||||
const ansiCloser = `[m`;
|
||||
|
||||
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'? `[96m${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 ? `[90mQuery: ${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
@@ -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
@@ -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']}};
|
||||
}
|
||||
}
|
||||
Executable
+49
@@ -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;
|
||||
}
|
||||
Executable
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -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";
|
||||
}
|
||||
Executable
+27
@@ -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;
|
||||
}
|
||||
Executable
+12
@@ -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";
|
||||
|
||||
}
|
||||
Executable
+16
@@ -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";
|
||||
Executable
+11
@@ -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 {}
|
||||
Executable
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -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";
|
||||
}
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
import {ApiError} from "./api.js";
|
||||
|
||||
export class PermissionError extends ApiError {
|
||||
constructor(message, permission){
|
||||
super(message);
|
||||
this.permission = permission;
|
||||
}
|
||||
}
|
||||
Executable
+11
@@ -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";
|
||||
}
|
||||
Executable
+26
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./authorization/super-admin.js";
|
||||
export * from "./authorization/authorization-info.js";
|
||||
|
||||
export * from "./errors/unauthorized.js";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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};
|
||||
@@ -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"
|
||||
},
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./typed-collection";
|
||||
export * from "./typed-cursor";
|
||||
Executable
+120
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./jwt/index.js"
|
||||
@@ -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};
|
||||
Executable
+11
@@ -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 };
|
||||
@@ -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};
|
||||
@@ -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};
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function cleanForId(valueToClean) {
|
||||
return valueToClean.toLowerCase().replace(/[\s\\+=/.\[\]{})(*&^%$#@!]/g, '-');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+25
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./deep-equals.js";
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user