Initial commit

This commit is contained in:
2024-04-17 22:54:13 +02:00
commit a8fdb455f4
162 changed files with 49158 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
/**
* @typedef {Object} AuthorizeOptions
* @property {string} [role] - Role which the user should be in
* @property {string[]} [roles] - Array of roles which the user should be in
*/
export class AuthorizeOptions {
/** @type {?string} **/
role;
/** @type {string[]} **/
roles;
constructor(roles) {
if(typeof roles === 'string') {
this.role = roles;
}
if (roles instanceof Array) {
this.roles = roles;
}
}
}
+154
View File
@@ -0,0 +1,154 @@
function normalizeIp(ip){
// Oddly enough, FF passing in the clientIp is different from chrome?!
return ip === "::ffff:127.0.0.1" ? "::1" : ip;
}
/**
* @typedef File
* @property {Buffer} buffer
* @property {string} encoding
* @property {string} fileName
* @property {string} mimetype
*/
/**
* Request context
*/
export class RequestContext{
/** @param {RequestContext} c */
constructor(c){
if(c){
this.parameters = c.parameters??{};
this.queryParameters = c.queryParameters??{};
this.body = c.body;
this.session = c.session;
this.method = c.method;
this.endpoint = c.endpoint;
this.clientIp = c.clientIp;
this.host = c.host;
this.headers = c.headers;
}
}
/** @type {Object<string,string>} */
parameters = {};
/** @type {Object<string,string>} */
queryParameters = {};
/** @type {*} */
body = undefined;
/** @type {AuthSession} */
session;
/** @type {'GET'|'POST'|'PATCH'|'DELETE'|'PUT'} */
method;
/** @type {string} */
endpoint;
/** @type {string} */
clientIp;
/** @type {string} */
host;
/** @type {Object} */
headers;
/** @type {string} */
rawBody = undefined;
get queryString(){
let query = Array.from(Object.entries(this.queryParameters));
if(query.length>0){
return "?" + query.map(
([key,value])=>
value!==null&&value!==undefined
? [encodeURIComponent(key), encodeURIComponent(value)].join('=')
: encodeURIComponent(key)
).join("&");
}else{
return "";
}
}
}
export class KoaRequestContext extends RequestContext{
constructor(koaCtx){
super({
parameters: koaCtx.params,
queryParameters: koaCtx.query,
body: koaCtx.request?.body,
rawBody: koaCtx.request?.rawBody,
session: koaCtx.state?.session,
method: koaCtx.method,
endpoint: koaCtx.path? koaCtx.path.slice((koaCtx.path.indexOf("/api/")??0) + '/api'.length) : '/',
clientIp: normalizeIp((
koaCtx.request.headers["X-Forwarded-For".toLowerCase()]
?? koaCtx.request.headers["X-Real-IP".toLowerCase()]
?? koaCtx.request.headers["X-Orig-IP".toLowerCase()]
?? koaCtx.request.ip
)?.toString() ?? undefined),
host: (koaCtx.request.headers["host"])?.toString() ?? undefined,
headers: koaCtx.request.headers
});
this.#koaCtx = koaCtx;
}
// Private
#koaCtx;
set(key, value) {
this.#koaCtx.set(key, value);
}
/** @type {request.Request} */
get req(){
return this.#koaCtx.req;
}
/** @type {request.Response} */
get res(){
return this.#koaCtx.res;
}
/** @type {string} */
get path(){
return this.#koaCtx.path;
}
set attachment(fileName){
return this.#koaCtx.attachment(fileName);
}
set type(type){
return this.#koaCtx.type = type;
}
/** @type {File} */
get file(){
return this.#koaCtx.file;
}
/** @type {[File]} */
get files(){
return this.#koaCtx.files;
}
set status(value) {
this.#koaCtx.status = value;
}
/** @type {Instance} */
get currentInstance(){
return this.#koaCtx.currentInstance;
}
/** @param {Instance} value */
set currentInstance(value) {
this.#koaCtx.currentInstance = value;
}
/** @type {AuthorizationInfo} */
get authorizationInfo() {
return this.#koaCtx.authorizationInfo;
}
set authorizationInfo(authorizationInfo) {
this.#koaCtx.authorizationInfo = authorizationInfo;
}
/** @type {UserInfo} */
get userInfo() {
return this.#koaCtx.userInfo;
}
set userInfo(userInfo) {
this.#koaCtx.userInfo = userInfo;
}
}
+110
View File
@@ -0,0 +1,110 @@
import "reflect-metadata";
/**
* @typedef {'GET'|'POST'|'PATCH'|'DELETE'|'PUT'} HttpMethod
*/
/**
* @typedef {EndpointOptions}
* @property {[function]} middlewares - Additional middlewares
* @property {AuthorizeOptions} authorization - Authorization info
*/
export class EndpointOptions {
/** @type {function[]} **/
middlewares = [];
/** @type {?AuthorizeOptions} **/
authorizeOptions;
/**
* @param {function[]} middlewares
* @param {?AuthorizeOptions=} [authorizeOptions]
*/
constructor(middlewares, authorizeOptions= null) {
this.middlewares = middlewares;
this.authorizeOptions = authorizeOptions;
}
}
/**
* @typedef {HttpEndpoint}
* @property {HttpMethod} method
* @property {string} route
* @property {?EndpointOptions} options
*/
export class HttpEndpoint {
/** @type {HttpMethod} **/
method;
/** @type {string} **/
route;
/** @type {?EndpointOptions} **/
options;
/** @type {function(KoaRequestContext, function)} **/
handler;
/**
* @param {HttpMethod} method
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(method, route, handler, options = null) {
this.method = method;
this.route = route;
this.handler = handler;
this.options = options;
}
}
export class HttpGet extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('GET', route, handler, options);
}
}
export class HttpPost extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('POST', route, handler, options);
}
}
export class HttpPatch extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('PATCH', route, handler, options);
}
}
export class HttpPut extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('PUT', route, handler, options);
}
}
export class HttpDelete extends HttpEndpoint {
/**
* @param {string} route
* @param {function(KoaRequestContext, function)} handler
* @param {?EndpointOptions=} [options]
*/
constructor(route, handler, options = null) {
super('DELETE', route, handler, options);
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./authorize";
export * from "./endpoint";
export * from "./router.js";
export * from "./context";
+104
View File
@@ -0,0 +1,104 @@
import {KoaRequestContext} from "./context";
import {hasRole, hasSite} from "../../middleware/authorization";
import Router from "@koa/router";
/**
*
* @param {string} path
*/
export class ApiRouter {
/** @type {string} **/
path;
/** @type {HttpEndpoint[]} **/
endpoints = [];
/** @type {?AuthorizeOptions} **/
authorizeOptions;
/** @type {Router} **/
koaRouter;
routerAuthValidator;
/**
* @param {string} path
* @param {?AuthorizeOptions=} [authorizeOptions]
*/
constructor(path, authorizeOptions = null) {
this.path = path;
// Define a matching router
this.koaRouter = new Router();
this.authorizeOptions = authorizeOptions;
this.routerAuthValidator = this.generateAuthorizeHandler(authorizeOptions);
}
/**
* @param {HttpEndpoint} endpoint
*/
registerEndpoint(endpoint) {
let method = endpoint.method;
let route = endpoint.route;
let authValidator = this.generateAuthorizeHandler(endpoint.options?.authorizeOptions);
let handler = async (ctx, next) => {
let reqCtx = new KoaRequestContext(ctx);
if(this.routerAuthValidator) {
await this.routerAuthValidator(reqCtx);
}
if(authValidator) {
await authValidator(reqCtx);
}
ctx.body = await Promise.resolve(endpoint.handler(reqCtx, next));
};
let methodMapping = {
'GET': 'get',
'POST': 'post',
'PATCH': 'patch',
'PUT': 'put',
'DELETE': 'delete'
};
if(endpoint.options?.middlewares) {
this.koaRouter[methodMapping[method]](route, ...endpoint.options.middlewares, handler);
} else {
this.koaRouter[methodMapping[method]](route, handler);
}
}
/**
* @param {?AuthorizeOptions} authorizeOptions
* @returns {(function(*, *): Promise<void>)|*}
*/
generateAuthorizeHandler(authorizeOptions) {
if (authorizeOptions) {
return async (/** @param {KoaRequestContext} **/ctx) => {
if (authorizeOptions.role || authorizeOptions.roles) {
const roles = [
authorizeOptions.role,
...(authorizeOptions.roles || [])
].filter(x => x);
await hasRole(roles, ctx);
const {parameters: {site}} = ctx;
if(site){
await hasSite(site, ctx);
}
}
}
}
return null;
}
/**
* @param {Router} app
*/
register(app) {
// Register it as middleware in the app (which may in turn have been a KoaRouter)
app.use(this.path, this.koaRouter.routes(), this.koaRouter.allowedMethods());
}
}
+12
View File
@@ -0,0 +1,12 @@
import Router from "@koa/router";
import {publicAlbumsApi} from "./public/albums";
import {s3Api} from "./public/s3/index.js";
import {privateInstancesApi} from "./private/instances.js";
import {privateAlbumsApi} from "./private/albums.js";
export const api = new Router();
publicAlbumsApi.register(api);
s3Api.register(api);
privateInstancesApi.register(api);
privateAlbumsApi.register(api);
+154
View File
@@ -0,0 +1,154 @@
import {ApiRouter, AuthorizeOptions, HttpDelete, HttpGet, HttpPatch, HttpPost, HttpPut} from "../api-routing/index.js";
import {BadRequestError, ValidationError} from "../../models/errors/index.js";
import {albums} from "../../services/albums.js";
import multer from "@koa/multer";
import {instances} from "../../services/instances.js";
const upload = multer();
class AlbumsApi extends ApiRouter {
constructor() {
super("/instances/:instanceId", new AuthorizeOptions(["instance-manager", "admin"]));
this.registerEndpoint(new HttpGet("/albums", this.fetchAlbums));
this.registerEndpoint(new HttpPost("/albums", this.create));
this.registerEndpoint(new HttpPut("/albums/:albumId", this.update));
this.registerEndpoint(new HttpPatch("/albums/:albumId", this.patch));
this.registerEndpoint(new HttpDelete("/albums/:albumId", this.delete));
this.registerEndpoint(new HttpPost("/albums/:albumId/move-up", this.moveUp));
this.registerEndpoint(new HttpPost("/albums/:albumId/move-down", this.moveDown));
this.registerEndpoint(new HttpPost("/albums/:albumId/upload-photos", this.uploadPhotos, {
middlewares: [upload.array('photos')]
}
));
this.registerEndpoint(new HttpDelete("/albums/:albumId/photos/:photoId", this.deletePhoto));
this.registerEndpoint(new HttpPatch("/albums/:albumId/photos/:photoId", this.patchPhoto));
this.registerEndpoint(new HttpPost("/albums/:albumId/photos/:photoId/move-up", this.movePhotoUp));
this.registerEndpoint(new HttpPost("/albums/:albumId/photos/:photoId/move-down", this.movePhotoDown));
}
/**
* @param {KoaRequestContext} ctx
*/
async fetchAlbums(ctx) {
const {parameters: {instanceId}} = ctx;
return {
data: await albums.getAlbums(instanceId)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async create({parameters: {instanceId}, body}) {
return {
data: await albums.createAlbum(instanceId, body)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async update({parameters: {instanceId, albumId}, body: {name, description, sort}}) {
sort = sort || "auto";
if (!["manual", "auto"].includes(sort)) {
throw new ValidationError([{
field: "sort",
message: `Parameter 'sort' can only be 'manual' or 'auto', not ${sort}.`
}]);
}
return {
data: await albums.updateAlbum({instanceId, albumId, name, description, sort})
};
}
/**
* @param {KoaRequestContext} ctx
*/
async patch({parameters: {instanceId, albumId}, body}) {
return {
data: await albums.patchAlbum(instanceId, albumId, body)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async delete(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
await albums.deleteAlbum(instanceId, albumId);
return {
data: await albums.getAlbums()
}
}
/**
* @param {KoaRequestContext} ctx
*/
async moveUp(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
return {
data: await albums.moveAlbum(instanceId, albumId, "up")
};
}
/**
* @param {KoaRequestContext} ctx
*/
async moveDown(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
return {
data: await albums.moveAlbum(instanceId, albumId, "down")
};
}
async uploadPhotos(ctx) {
const {parameters: {instanceId, albumId}} = ctx;
if (!ctx.files) {
throw new BadRequestError("No files were uploaded for field 'photos'");
}
return {
data: await albums.uploadPhotos(instanceId, albumId, ctx.files)
};
}
async deletePhoto(ctx) {
const {parameters: {instanceId, albumId, photoId}} = ctx;
return {
data: await albums.deletePhoto(instanceId, albumId, photoId)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async patchPhoto({parameters: {instanceId, albumId}, body}) {
return {
data: await albums.patchPhoto(instanceId, albumId, parameters.photoId, body)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async movePhotoUp(ctx) {
const {parameters: {instanceId, albumId, photoId}} = ctx;
return {
data: await albums.movePhoto(instanceId, albumId, photoId, "up")
};
}
/**
* @param {KoaRequestContext} ctx
*/
async movePhotoDown(ctx) {
const {parameters: {instanceId, albumId, photoId}} = ctx;
return {
data: await albums.movePhoto(instanceId, albumId, photoId, "down")
};
}
}
// Singleton
const instance = new AlbumsApi();
export {instance as privateAlbumsApi};
+75
View File
@@ -0,0 +1,75 @@
import {ApiRouter, AuthorizeOptions, HttpGet, HttpPatch, HttpPost, HttpPut} from "../api-routing/index.js";
import {instances} from "../../services/instances.js";
import {BadRequestError} from "../../models/errors/index.js";
import {albums} from "../../services/albums.js";
import multer from "@koa/multer";
const upload = multer();
class InstancesApi extends ApiRouter {
constructor() {
super("/instances", new AuthorizeOptions(["instance-manager", "admin"]));
this.registerEndpoint(new HttpGet("/", this.fetchInstances));
this.registerEndpoint(new HttpPost("/", this.createInstance, {
authorizeOptions: new AuthorizeOptions("admin")
}));
this.registerEndpoint(new HttpGet("/:instanceId", this.fetchInstance));
this.registerEndpoint(new HttpPut("/:instanceId", this.updateInstance));
this.registerEndpoint(new HttpPost("/:instanceId/upload-cover-photo", this.uploadPhoto, {
middlewares: [upload.array('cover-photo')]
}
));
}
/**
* @param {KoaRequestContext} ctx
*/
async fetchInstances(ctx) {
return {
data: await instances.getInstances(ctx.userInfo.available_photos_sites)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async fetchInstance(ctx) {
const {parameters: {instanceId}} = ctx;
return {
data: await instances.getInstance(instanceId)
};
}
/**
* @param {KoaRequestContext} ctx
*/
async updateInstance({parameters, body}) {
return {
data: await instances.updateInstance({
...body,
instanceId: parameters.instanceId
})
};
}
/**
* @param {KoaRequestContext} ctx
*/
async createInstance({body}) {
return {
data: await instances.createInstance(body)
};
}
async uploadPhoto(ctx) {
const {parameters: {instanceId}} = ctx;
if (!ctx.files) {
throw new BadRequestError("No file was uploaded for field 'photo'");
}
return {
data: await instances.setCoverPhoto(instanceId, ctx.files[0])
};
}
}
// Singleton
const instance = new InstancesApi();
export {instance as privateInstancesApi};
+25
View File
@@ -0,0 +1,25 @@
import {ApiRouter, HttpGet} from "../../api-routing";
import {albums} from "../../../services/albums.js";
class PublicAlbumsApi extends ApiRouter {
constructor() {
super("/albums");
this.registerEndpoint(new HttpGet("/", this.getAll))
}
/**
* @param {KoaRequestContext} ctx
*/
async getAll(ctx) {
return {
data:{
albums: await albums.getAlbums(ctx.currentInstance.instanceId),
instance: ctx.currentInstance
}
};
}
}
// Singleton
const instance = new PublicAlbumsApi();
export {instance as publicAlbumsApi};
+61
View File
@@ -0,0 +1,61 @@
import {ApiRouter, HttpGet} from "../../api-routing";
import {minio} from "../../../services/minio.js";
import {thumbnailer} from "../../../services/thumbnailer.js";
class S3Api extends ApiRouter {
constructor() {
super("/s3");
this.registerEndpoint(new HttpGet("/:filePath(.*)", this.getFile))
}
/**
* @param {KoaRequestContext} ctx
*/
async getFile(ctx) {
const {parameters: {filePath}, queryParameters: {width, height, percentage, fit}} = ctx;
const stream = await minio.downloadFile(filePath);
const headersToCopy = [
"content-type",
"content-length",
"last-modified",
"etag"
];
for(let header in stream.headers) {
if(headersToCopy.includes(header)) {
ctx.set(header, stream.headers[header]);
}
}
const expiryDate = new Date();
expiryDate.setSeconds(expiryDate.getSeconds() + 10);
ctx.set("expires", expiryDate.toISOString());
ctx.set("content-location", filePath);
ctx.set("cache-control", "public, max-age=10");
ctx.set("date", stream.headers["last-modified"]);
for(let header in ctx.headers) {
if(header.toLowerCase() === "if-none-match" &&
ctx.headers[header] === stream.headers["etag"]) {
ctx.status = 304;
return null;
} else if(header.toLowerCase() === "if-modified-since" &&
ctx.headers[header] === stream.headers["last-modified"]) {
ctx.status = 304;
return null;
}
}
if(width || height || percentage) {
// return thumbnail
const options = {};
if(!isNaN(Number(width))) options.width = Number(width);
if(!isNaN(Number(height))) options.height = Number(height);
if(!isNaN(Number(percentage))) options.percentage = Number(percentage);
if(fit) options.fit = fit;
return await thumbnailer.fromStream(stream, options, fit);
}
return stream;
}
}
// Singleton
const instance = new S3Api();
export {instance as s3Api};