454 lines
14 KiB
TypeScript
454 lines
14 KiB
TypeScript
import {
|
|
AccountProvisioning,
|
|
AccountQuota,
|
|
Feature,
|
|
OrgProvisioning,
|
|
OrgQuota,
|
|
Provisioner,
|
|
Provisioning,
|
|
ProvisioningStatus,
|
|
VaultProvisioning,
|
|
VaultQuota,
|
|
} 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 { Account, AccountID } from "@padloc/core/src/account";
|
|
import { Org, OrgID } from "@padloc/core/src/org";
|
|
import { AsSerializable } from "@padloc/core/src/encoding";
|
|
|
|
export class DefaultAccountQuota extends Config implements AccountQuota {
|
|
@ConfigParam("number")
|
|
vaults = 1;
|
|
|
|
@ConfigParam("number")
|
|
orgs = 3;
|
|
}
|
|
|
|
export class DefaultAccountProvisioning
|
|
extends Config
|
|
implements
|
|
Pick<
|
|
AccountProvisioning,
|
|
"status" | "statusLabel" | "statusMessage" | "actionUrl" | "actionLabel" | "quota" | "disableFeatures"
|
|
>
|
|
{
|
|
@ConfigParam()
|
|
status: ProvisioningStatus = ProvisioningStatus.Active;
|
|
|
|
@ConfigParam()
|
|
statusLabel: string = "";
|
|
|
|
@ConfigParam()
|
|
statusMessage: string = "";
|
|
|
|
@ConfigParam()
|
|
actionUrl?: string;
|
|
|
|
@ConfigParam()
|
|
actionLabel?: string;
|
|
|
|
@ConfigParam(DefaultAccountQuota)
|
|
quota: DefaultAccountQuota = new DefaultAccountQuota();
|
|
|
|
@ConfigParam("string[]")
|
|
disableFeatures: Feature[] = [];
|
|
}
|
|
|
|
export class SimpleProvisionerConfig 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[];
|
|
}
|
|
|
|
class ProvisioningEntry extends AccountProvisioning {
|
|
constructor(vals: Partial<ProvisioningEntry> = {}) {
|
|
super();
|
|
Object.assign(this, vals);
|
|
}
|
|
|
|
id: string = "";
|
|
|
|
@AsSerializable(OrgQuota)
|
|
orgQuota: OrgQuota = new OrgQuota();
|
|
|
|
@AsSerializable(VaultQuota)
|
|
vaultQuota: VaultQuota = new VaultQuota();
|
|
|
|
scheduledUpdates: ScheduledProvisioningUpdate[] = [];
|
|
|
|
metaData?: { [prop: string]: string } = undefined;
|
|
}
|
|
|
|
export class SimpleProvisioner implements Provisioner {
|
|
constructor(public readonly config: SimpleProvisionerConfig, private readonly storage: Storage) {}
|
|
|
|
private 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,
|
|
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,
|
|
});
|
|
|
|
try {
|
|
const { status, statusLabel, statusMessage, actionUrl, actionLabel } = await this.storage.get(
|
|
ProvisioningEntry,
|
|
"[default]"
|
|
);
|
|
|
|
provisioning.status = status;
|
|
provisioning.statusLabel = statusLabel;
|
|
provisioning.statusMessage = statusMessage;
|
|
provisioning.actionUrl = actionUrl;
|
|
provisioning.actionLabel = actionLabel;
|
|
} catch (e) {}
|
|
|
|
return provisioning;
|
|
}
|
|
|
|
private async _getOrgProvisioning(account: Account, { id }: { id: OrgID }) {
|
|
const org = await this.storage.get(Org, id);
|
|
const { email, id: accountId } = org.isOwner(account) ? account : await this.storage.get(Account, org.owner);
|
|
const { status, statusLabel, statusMessage, orgQuota, vaultQuota } = await this._getProvisioningEntry({
|
|
email,
|
|
accountId,
|
|
});
|
|
const vaults = org.getVaultsForMember(account);
|
|
return {
|
|
org: new OrgProvisioning({
|
|
orgId: org.id,
|
|
status,
|
|
statusLabel,
|
|
statusMessage,
|
|
quota: orgQuota,
|
|
}),
|
|
vaults: vaults.map(
|
|
(v) =>
|
|
new VaultProvisioning({
|
|
vaultId: v.id,
|
|
status,
|
|
statusLabel,
|
|
statusMessage,
|
|
quota: vaultQuota,
|
|
})
|
|
),
|
|
};
|
|
}
|
|
|
|
async getProvisioning({ email, accountId }: { email: string; accountId?: AccountID }) {
|
|
const provisioningEntry = await this._getProvisioningEntry({ email, accountId });
|
|
const provisioning = new Provisioning({
|
|
account: new AccountProvisioning({
|
|
...provisioningEntry,
|
|
quota: this.config.default.quota,
|
|
disableFeatures: this.config.default.disableFeatures,
|
|
}),
|
|
});
|
|
if (accountId) {
|
|
const account = await this.storage.get(Account, accountId);
|
|
const orgs = await Promise.all(account.orgs.map((org) => this._getOrgProvisioning(account, org)));
|
|
provisioning.orgs = orgs.map((o) => o.org);
|
|
provisioning.vaults = [
|
|
new VaultProvisioning({
|
|
vaultId: account.mainVault.id,
|
|
status: provisioningEntry.status,
|
|
statusLabel: provisioningEntry.statusLabel,
|
|
statusMessage: provisioningEntry.statusMessage,
|
|
quota: provisioningEntry.vaultQuota,
|
|
}),
|
|
...orgs.flatMap((o) => o.vaults),
|
|
];
|
|
}
|
|
|
|
return provisioning;
|
|
}
|
|
|
|
async accountDeleted(_params: { email: string; accountId?: string }): Promise<void> {
|
|
// const id = await getIdFromEmail(email);
|
|
// try {
|
|
// const provisioning = await this.storage.get(ProvisioningEntry, id);
|
|
// if (provisioning) {
|
|
// await this.storage.delete(provisioning);
|
|
// }
|
|
// } catch (e) {
|
|
// if (e.code !== ErrorCode.NOT_FOUND) {
|
|
// throw e;
|
|
// }
|
|
// }
|
|
}
|
|
|
|
async init() {
|
|
return this._startServer();
|
|
}
|
|
|
|
private _applyUpdate(entry: ProvisioningEntry, update: ProvisioningUpdate) {
|
|
entry.status = update.status;
|
|
entry.statusLabel = update.statusLabel;
|
|
entry.statusMessage = update.statusMessage;
|
|
entry.actionUrl = update.actionUrl || this.config.default.actionUrl;
|
|
entry.actionLabel = update.actionLabel || this.config.default.actionLabel;
|
|
entry.metaData = update.metaData;
|
|
}
|
|
|
|
private async _handleRequest({ 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;
|
|
}
|
|
|
|
private 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._handleRequest(request);
|
|
} catch (e) {
|
|
httpRes.statusCode = 500;
|
|
httpRes.end("Unexpected Error");
|
|
return;
|
|
}
|
|
|
|
httpRes.statusCode = 200;
|
|
httpRes.end();
|
|
}
|
|
|
|
private 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
|
|
)
|
|
);
|
|
}
|
|
|
|
async _startServer() {
|
|
const server = createServer(async (httpReq, httpRes) => {
|
|
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);
|
|
break;
|
|
case "GET":
|
|
return this._handleGet(httpReq, httpRes);
|
|
default:
|
|
httpRes.statusCode = 405;
|
|
httpRes.end();
|
|
}
|
|
});
|
|
|
|
server.listen(this.config.port);
|
|
}
|
|
}
|