388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import {
|
|
AccountProvisioning,
|
|
AccountQuota,
|
|
BasicProvisioner,
|
|
Provisioning,
|
|
ProvisioningStatus,
|
|
} from "@padloc/core/src/provisioning";
|
|
import { getIdFromEmail } from "@padloc/core/src/util";
|
|
import { Storage } from "@padloc/core/src/storage";
|
|
import { ErrorCode } from "@padloc/core/src/error";
|
|
import { Config, ConfigParam } from "@padloc/core/src/config";
|
|
import { createServer, IncomingMessage, ServerResponse } from "http";
|
|
import { readBody } from "../transport/http";
|
|
import { AccountID } from "@padloc/core/src/account";
|
|
|
|
export class DefaultAccountQuota extends Config implements AccountQuota {
|
|
@ConfigParam("number")
|
|
vaults = 1;
|
|
|
|
@ConfigParam("number")
|
|
storage = 1000;
|
|
}
|
|
|
|
export class DefaultAccountProvisioning
|
|
extends Config
|
|
implements
|
|
Pick<AccountProvisioning, "status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel" | "quota">
|
|
{
|
|
@ConfigParam()
|
|
status: ProvisioningStatus = ProvisioningStatus.Active;
|
|
|
|
@ConfigParam()
|
|
statusLabel: string = "";
|
|
|
|
@ConfigParam()
|
|
statusMessage: string = "";
|
|
|
|
@ConfigParam()
|
|
actionUrl?: string;
|
|
|
|
@ConfigParam()
|
|
actionLabel?: string;
|
|
|
|
@ConfigParam(DefaultAccountQuota)
|
|
quota: DefaultAccountQuota = new DefaultAccountQuota();
|
|
}
|
|
|
|
export class ApiProvisionerConfig extends Config {
|
|
@ConfigParam("number")
|
|
port: number = 4000;
|
|
|
|
@ConfigParam("string", true)
|
|
apiKey?: string;
|
|
|
|
@ConfigParam(DefaultAccountProvisioning)
|
|
default: DefaultAccountProvisioning = new DefaultAccountProvisioning();
|
|
}
|
|
|
|
interface ProvisioningUpdate {
|
|
email: string;
|
|
|
|
status: ProvisioningStatus;
|
|
|
|
statusLabel: string;
|
|
|
|
statusMessage: string;
|
|
|
|
actionUrl?: string;
|
|
|
|
actionLabel?: string;
|
|
|
|
scheduled?: ScheduledProvisioningUpdate[];
|
|
|
|
metaData?: { [prop: string]: string };
|
|
}
|
|
|
|
interface ScheduledProvisioningUpdate extends ProvisioningUpdate {
|
|
time: number;
|
|
}
|
|
|
|
interface ProvisioningRequest {
|
|
default: ProvisioningUpdate;
|
|
updates: ProvisioningUpdate[];
|
|
}
|
|
|
|
export class ProvisioningEntry extends Provisioning {
|
|
constructor(vals: Partial<ProvisioningEntry> = {}) {
|
|
super();
|
|
Object.assign(this, vals);
|
|
}
|
|
|
|
id: string = "";
|
|
|
|
scheduledUpdates: ScheduledProvisioningUpdate[] = [];
|
|
|
|
metaData?: any = undefined;
|
|
}
|
|
|
|
export class ApiProvisioner extends BasicProvisioner {
|
|
constructor(public readonly config: ApiProvisionerConfig, public readonly storage: Storage) {
|
|
super(storage);
|
|
}
|
|
|
|
protected async _getProvisioningEntry({ email, accountId }: { email: string; accountId?: string | undefined }) {
|
|
const id = await getIdFromEmail(email);
|
|
|
|
try {
|
|
const entry = await this.storage.get(ProvisioningEntry, id);
|
|
entry.scheduledUpdates.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
|
|
const dueUpdate = entry.scheduledUpdates.filter((u) => new Date(u.time) <= new Date()).pop();
|
|
if (dueUpdate) {
|
|
this._applyUpdate(entry, dueUpdate);
|
|
entry.scheduledUpdates = entry.scheduledUpdates.filter((u) => new Date(u.time) > new Date());
|
|
await this.storage.save(entry);
|
|
}
|
|
return entry;
|
|
} catch (e) {
|
|
if (e.code !== ErrorCode.NOT_FOUND) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
const provisioning = new ProvisioningEntry({
|
|
id,
|
|
account: new AccountProvisioning({
|
|
email,
|
|
accountId,
|
|
status: this.config.default.status,
|
|
statusLabel: this.config.default.statusLabel,
|
|
statusMessage: this.config.default.statusMessage,
|
|
actionUrl: this.config.default.actionUrl,
|
|
actionLabel: this.config.default.actionLabel,
|
|
quota: this.config.default.quota,
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const {
|
|
account: { status, statusLabel, statusMessage, actionUrl, actionLabel },
|
|
} = await this.storage.get(ProvisioningEntry, "[default]");
|
|
|
|
provisioning.account.status = status;
|
|
provisioning.account.statusLabel = statusLabel;
|
|
provisioning.account.statusMessage = statusMessage;
|
|
provisioning.account.actionUrl = actionUrl;
|
|
provisioning.account.actionLabel = actionLabel;
|
|
} catch (e) {}
|
|
|
|
return provisioning;
|
|
}
|
|
|
|
async getProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) {
|
|
return this._getProvisioningEntry({ email, accountId });
|
|
}
|
|
|
|
async accountDeleted({ email }: { email: string; accountId?: string }): Promise<void> {
|
|
const id = await getIdFromEmail(email);
|
|
try {
|
|
const provisioning = await this.storage.get(ProvisioningEntry, id);
|
|
if (provisioning) {
|
|
provisioning.account.status = ProvisioningStatus.Deleted;
|
|
}
|
|
await this.storage.save(provisioning);
|
|
} catch (e) {
|
|
if (e.code !== ErrorCode.NOT_FOUND) {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
async init() {
|
|
return this._startServer();
|
|
}
|
|
|
|
private _applyUpdate(entry: ProvisioningEntry, update: ProvisioningUpdate) {
|
|
entry.account.status = update.status;
|
|
entry.account.statusLabel = update.statusLabel;
|
|
entry.account.statusMessage = update.statusMessage;
|
|
entry.account.actionUrl = update.actionUrl || this.config.default.actionUrl;
|
|
entry.account.actionLabel = update.actionLabel || this.config.default.actionLabel;
|
|
entry.metaData = update.metaData;
|
|
}
|
|
|
|
private async _handleUpdateRequest({ default: defaultProv, updates = [] }: ProvisioningRequest) {
|
|
if (defaultProv) {
|
|
const entry = new ProvisioningEntry(defaultProv);
|
|
entry.id = "[default]";
|
|
await this.storage.save(entry);
|
|
}
|
|
|
|
for (const update of updates) {
|
|
const entry = (await this._getProvisioningEntry({ email: update.email })) as ProvisioningEntry;
|
|
this._applyUpdate(entry, update);
|
|
entry.scheduledUpdates = update.scheduled || [];
|
|
await this.storage.save(entry);
|
|
}
|
|
}
|
|
|
|
private _validateUpdate(update: ProvisioningUpdate) {
|
|
const validStatuses = Object.values(ProvisioningStatus);
|
|
|
|
if (!validStatuses.includes(update.status)) {
|
|
return `'updates.status' parameter must be one of ${validStatuses.map((s) => `"${s}"`).join(", ")}`;
|
|
}
|
|
|
|
if (typeof update.statusLabel !== "string") {
|
|
return "'updates.statusLabel' parameter must be a string";
|
|
}
|
|
|
|
if (typeof update.statusMessage !== "string") {
|
|
return "'updates.statusMessage' parameter must be a string";
|
|
}
|
|
|
|
if (typeof update.scheduled !== "undefined" && !Array.isArray(update.scheduled)) {
|
|
return "'updates.scheduled' parameter must be an array!";
|
|
}
|
|
|
|
if (typeof update.actionUrl !== "undefined" && typeof update.actionUrl !== "string") {
|
|
return "'updates.actionUrl' parameter must be a string";
|
|
}
|
|
|
|
if (update.actionUrl && !update.actionLabel) {
|
|
return "If 'updates.actionUrl' is provided, 'updates.actionLabel' must be provided as well.";
|
|
}
|
|
|
|
if (typeof update.actionLabel !== "undefined" && typeof update.actionLabel !== "string") {
|
|
return "'updates.actionLabel' parameter must be a string";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private _validate(request: any): string | null {
|
|
if (!request.updates && !request.default) {
|
|
return "Request must contain either 'updates' or 'default' parameter";
|
|
}
|
|
|
|
if (request.default) {
|
|
const err = this._validateUpdate(request.default);
|
|
if (err) {
|
|
return err;
|
|
}
|
|
}
|
|
|
|
if (request.updates && !Array.isArray(request.updates)) {
|
|
return "'update' parameter should be an Array";
|
|
}
|
|
|
|
for (const update of request.updates || []) {
|
|
if (!update.email || typeof update.email !== "string") {
|
|
return "'updates.email' parameter must be a non-empty string";
|
|
}
|
|
|
|
const error = this._validateUpdate(update);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
if (update.scheduled) {
|
|
if (!Array.isArray(update.scheduled)) {
|
|
return "'updates.scheduled' must be an array";
|
|
}
|
|
|
|
for (const scheduled of update.scheduled) {
|
|
const ts = new Date(scheduled.time).getTime();
|
|
|
|
if (isNaN(ts) || ts < Date.now()) {
|
|
return "'scheduled.time' must be a valid time in the future!";
|
|
}
|
|
|
|
const error = this._validateUpdate(scheduled);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
protected async _handlePost(httpReq: IncomingMessage, httpRes: ServerResponse) {
|
|
let request: ProvisioningRequest;
|
|
|
|
try {
|
|
const body = await readBody(httpReq);
|
|
request = JSON.parse(body);
|
|
} catch (e) {
|
|
httpRes.statusCode = 400;
|
|
httpRes.end("Failed to read request body.");
|
|
return;
|
|
}
|
|
|
|
const validationError = this._validate(request);
|
|
if (validationError) {
|
|
httpRes.statusCode = 400;
|
|
httpRes.end(validationError);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this._handleUpdateRequest(request);
|
|
} catch (e) {
|
|
httpRes.statusCode = 500;
|
|
httpRes.end("Unexpected Error");
|
|
return;
|
|
}
|
|
|
|
httpRes.statusCode = 200;
|
|
httpRes.end();
|
|
}
|
|
|
|
protected async _handleGet(httpReq: IncomingMessage, httpRes: ServerResponse) {
|
|
const email = new URL(httpReq.url!, "http://localhost").searchParams.get("email");
|
|
|
|
if (!email) {
|
|
httpRes.statusCode = 400;
|
|
httpRes.end("Missing parameter: 'email'");
|
|
return;
|
|
}
|
|
|
|
let entry: ProvisioningEntry;
|
|
|
|
try {
|
|
const id = await getIdFromEmail(email);
|
|
entry = await this.storage.get(ProvisioningEntry, id);
|
|
} catch (e) {
|
|
if (e.code === ErrorCode.NOT_FOUND) {
|
|
httpRes.statusCode = 404;
|
|
httpRes.end();
|
|
return;
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
const { accountId, status, statusLabel, statusMessage, actionUrl, actionLabel, scheduledUpdates, metaData } =
|
|
entry.toRaw();
|
|
|
|
httpRes.statusCode = 200;
|
|
httpRes.end(
|
|
JSON.stringify(
|
|
{
|
|
accountId,
|
|
status,
|
|
statusLabel,
|
|
statusMessage,
|
|
actionUrl,
|
|
actionLabel,
|
|
scheduledUpdates,
|
|
metaData,
|
|
},
|
|
null,
|
|
4
|
|
)
|
|
);
|
|
}
|
|
|
|
protected async _handleRequest(httpReq: IncomingMessage, httpRes: ServerResponse) {
|
|
if (this.config.apiKey) {
|
|
let authHeader = httpReq.headers["authorization"];
|
|
authHeader = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
|
const apiKeyMatch = authHeader?.match(/^Bearer (.+)$/);
|
|
if (!apiKeyMatch || apiKeyMatch[1] !== this.config.apiKey) {
|
|
httpRes.statusCode = 401;
|
|
httpRes.end();
|
|
return;
|
|
}
|
|
}
|
|
|
|
switch (httpReq.method) {
|
|
case "POST":
|
|
return this._handlePost(httpReq, httpRes);
|
|
case "GET":
|
|
return this._handleGet(httpReq, httpRes);
|
|
default:
|
|
httpRes.statusCode = 405;
|
|
httpRes.end();
|
|
}
|
|
}
|
|
|
|
private async _startServer() {
|
|
const server = createServer((req, res) => this._handleRequest(req, res));
|
|
|
|
server.listen(this.config.port);
|
|
}
|
|
}
|