smc-hub
Version:
CoCalc: Backend webserver component
412 lines (367 loc) • 12.5 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
// Copy Operations Provider
// Used in the "Client"
const access = require("./access");
import { callback2 } from "smc-util/async-utils";
import * as message from "smc-util/message";
const { one_result } = require("./postgres");
import { is_valid_uuid_string, to_json } from "smc-util/misc";
import { ProjectControlFunction } from "smc-hub/servers/project-control";
type WhereQueries = ({ [query: string]: string } | string)[];
interface CopyOp {
copy_path_id: any;
time: any;
source_project_id: any;
source_path: any;
target_project_id: any;
target_path: any;
overwrite_newer: any;
delete_missing: any;
backup: any;
started: any;
finished: any;
scheduled: any;
error: any;
}
// this is specific to queries built here
function sanitize(
val: number | string,
deflt: number,
max: number,
name
): number {
if (val != null) {
const o = typeof val == "string" ? parseInt(val) : val;
if (isNaN(o) || o < 0 || o > max) {
throw new Error(
`ILLEGAL VALUE ${name}='${val}' (must be in [0, ${max}])`
);
}
return o;
} else {
return deflt;
}
}
// thrown errors are an object, but the response needs a string
function err2str(err: string | { message?: string }) {
if (typeof err === "string") {
return err;
} else if (err.message != null) {
return err.message;
} else {
return `ERROR: ${to_json(err)}`;
}
}
// transforms copy_op data from the database to the specific object we want to return
function row_to_copy_op(copy_op): CopyOp {
return {
copy_path_id: copy_op.id,
time: copy_op.time,
source_project_id: copy_op.source_project_id,
source_path: copy_op.source_path,
target_project_id: copy_op.target_project_id,
target_path: copy_op.target_path,
overwrite_newer: copy_op.overwrite_newer,
delete_missing: copy_op.delete_missing,
backup: copy_op.backup,
started: copy_op.started,
finished: copy_op.finished,
scheduled: copy_op.scheduled,
error: copy_op.error,
};
}
export class CopyPath {
private client: any;
private dbg: (method: string) => (msg: string) => void;
private err: (method: string) => (msg: string) => void;
private throw: (msg: string) => void;
constructor(client) {
this.client = client;
this._init_errors();
this.copy = this.copy.bind(this);
this.status = this.status.bind(this);
this.delete = this.delete.bind(this);
this._status_query = this._status_query.bind(this);
this._status_single = this._status_single.bind(this);
this._get_status = this._get_status.bind(this);
this._read_access = this._read_access.bind(this);
this._write_access = this._write_access.bind(this);
}
private _init_errors(): void {
// client.dbg returns a function
this.dbg = function (method: string): (msg: string) => void {
return this.client.dbg(`CopyPath::${method}`);
};
this.err = function (method: string): (msg: string) => void {
return (msg) => {
throw new Error(`CopyPath::${method}: ${msg}`);
};
};
this.throw = (msg: string) => {
throw new Error(msg);
};
}
async copy(mesg): Promise<void> {
this.client.touch();
try {
// prereq checks
if (!is_valid_uuid_string(mesg.src_project_id)) {
this.throw(`src_project_id='${mesg.src_project_id}' not valid`);
}
if (!is_valid_uuid_string(mesg.target_project_id)) {
this.throw(`target_project_id='${mesg.target_project_id}' not valid`);
}
if (mesg.src_path == null) {
this.throw("src_path must be defined");
}
// check read/write access
const write = this._write_access(mesg.target_project_id);
const read = this._read_access(mesg.src_project_id);
await Promise.all([write, read]);
// get the "project" for issuing commands
const projectControl: ProjectControlFunction = this.client.compute_server;
const project = projectControl(mesg.src_project_id);
// do the copy
const copy_id = await project.copyPath({
path: mesg.src_path,
target_project_id: mesg.target_project_id,
target_path: mesg.target_path,
overwrite_newer: mesg.overwrite_newer,
delete_missing: mesg.delete_missing,
backup: mesg.backup,
timeout: mesg.timeout,
wait_until_done: mesg.wait_until_done,
scheduled: mesg.scheduled,
});
// if we're still here, the copy was ok!
if (copy_id != null) {
// we only expect a copy_id in kucalc mode
const resp = message.copy_path_between_projects_response({
id: mesg.id,
copy_path_id: copy_id,
});
this.client.push_to_client(resp);
} else {
this.client.push_to_client(message.success({ id: mesg.id }));
}
} catch (err) {
this.client.error_to_client({ id: mesg.id, error: err2str(err) });
}
}
async status(mesg): Promise<void> {
this.client.touch();
//const dbg = this.dbg("status");
// src_project_id, target_project_id and optionally src_path + offset (limit is 1000)
const search_many =
mesg.src_project_id != null || mesg.target_project_id != null;
if (!search_many && mesg.copy_path_id == null) {
this.client.error_to_client({
id: mesg.id,
error:
"'copy_path_id' (UUID) of a copy operation or 'src_project_id/target_project_id' must be defined",
});
return;
}
if (search_many) {
await this._status_query(mesg);
} else {
await this._status_single(mesg);
}
}
private async _status_query(mesg): Promise<void> {
const dbg = this.dbg("status_query");
const err = this.err("status_query");
try {
// prereq checks -- at least src or target must be set
if (mesg.src_project_id == null && mesg.target_project_id == null) {
// serious error: this should never happen, actually
err(
`At least one of "src_project_id" or "target_project_id" must be given!`
);
}
// constructing the query
const where: WhereQueries = [];
if (mesg.src_project_id != null) {
await this._read_access(mesg.src_project_id);
where.push({ "source_project_id = $::UUID": mesg.src_project_id });
}
if (mesg.target_project_id != null) {
await this._write_access(mesg.target_project_id);
where.push({ "target_project_id = $::UUID": mesg.target_project_id });
}
if (mesg.src_path != null) {
where.push({ "source_path = $": mesg.src_path });
}
// all failed ones are implicitly also finished
if (mesg.failed === true || mesg.failed === "true") {
where.push("error IS NOT NULL");
mesg.pending = false;
}
if (mesg.pending === true || mesg.pending === "true") {
where.push("finished IS NULL");
} else {
where.push("finished IS NOT NULL");
}
// … and also sanitizing input!
const offset = sanitize(mesg.offset, 0, 100 * 1000, "offset");
const limit = sanitize(mesg.limit, 1000, 1000, "limit");
dbg(`offset=${offset} limit=${limit}`);
// essentially, we want to fill up and return this array
const copy_ops: CopyOp[] = [];
const status_data = await callback2(this.client.database._query, {
query: "SELECT * FROM copy_paths",
where,
offset,
limit,
order_by: "time DESC", // most recent first
});
if (status_data == null) {
this.throw(
"Can't find copy operations for given src_project_id/target_project_id"
);
}
for (const row of Array.from(status_data.rows)) {
// be explicit about what we return
copy_ops.push(row_to_copy_op(row));
}
// we're good
this.client.push_to_client(
message.copy_path_status_response({
id: mesg.id,
data: copy_ops,
})
);
} catch (err) {
this.client.error_to_client({ id: mesg.id, error: err2str(err) });
}
}
private async _get_status(mesg): Promise<CopyOp | undefined> {
if (mesg.copy_path_id == null) {
this.throw("ERROR: copy_path_id missing");
}
const dbg = this.dbg("_get_status");
const where: WhereQueries = [{ "id = $::UUID": mesg.copy_path_id }];
// not_yet_done is set internally for deleting a scheduled copy op
if (mesg.not_yet_done) {
where.push("scheduled IS NOT NULL");
where.push("finished IS NULL");
}
// get the status info
const statuses = await callback2(this.client.database._query, {
query: "SELECT * FROM copy_paths",
where,
});
const copy_op: CopyOp = (() => {
let copy_op;
one_result((_, x) => {
if (x == null) {
if (mesg.not_yet_done) {
this.throw(
`Copy operation '${mesg.copy_path_id}' either does not exist or already finished`
);
} else {
this.throw(
`Can't find copy operation with ID=${mesg.copy_path_id}`
);
}
} else {
copy_op = x;
dbg(`copy_op=${to_json(copy_op)}`);
}
})(undefined, statuses);
return copy_op;
})();
if (copy_op == null) {
this.throw(`Can't find copy operation with ID=${mesg.copy_path_id}`);
return;
}
// check read/write access
const write = this._write_access(copy_op.target_project_id);
const read = this._read_access(copy_op.source_project_id);
await Promise.all([write, read]);
return copy_op;
}
private async _status_single(mesg): Promise<void> {
try {
const copy_op = await this._get_status(mesg);
// be explicit about what we return
const data = row_to_copy_op(copy_op);
this.client.push_to_client(
message.copy_path_status_response({ id: mesg.id, data })
);
} catch (err) {
this.client.error_to_client({ id: mesg.id, error: err2str(err) });
}
}
async delete(mesg): Promise<void> {
this.client.touch();
const dbg = this.dbg("delete");
// this filters possible results
mesg.not_yet_done = true;
try {
const copy_op = await this._get_status(mesg);
if (copy_op == null) {
this.client.error_to_client({
id: mesg.id,
error: `opy op '${mesg.copy_path_id}' cannot be deleted.`,
});
} else {
await callback2(this.client.database._query, {
query: "DELETE FROM copy_paths",
where: { "id = $::UUID": mesg.copy_path_id },
});
// no error
this.client.push_to_client(
message.copy_path_status_response({
id: mesg.id,
data: `copy_path_id = '${mesg.copy_path_id}' deleted`,
})
);
}
} catch (err) {
dbg(`stauts err=${err2str(err)}`);
this.client.error_to_client({ id: mesg.id, error: err2str(err) });
}
}
private async _read_access(src_project_id): Promise<boolean> {
if (!is_valid_uuid_string(src_project_id)) {
this.throw(`invalid src_project_id=${src_project_id}`);
}
const read_ok = await callback2(access.user_has_read_access_to_project, {
project_id: src_project_id,
account_id: this.client.account_id,
account_groups: this.client.groups,
database: this.client.database,
});
// this.dbg("_read_access")(read_ok);
if (!read_ok) {
this.throw(
`ACCESS BLOCKED -- No read access to source project -- ${src_project_id}`
);
return false;
}
return true;
}
private async _write_access(target_project_id): Promise<boolean> {
if (!is_valid_uuid_string(target_project_id)) {
this.throw(`invalid target_project_id=${target_project_id}`);
}
const write_ok = await callback2(access.user_has_write_access_to_project, {
database: this.client.database,
project_id: target_project_id,
account_id: this.client.account_id,
account_groups: this.client.groups,
});
// this.dbg("_write_access")(write_ok);
if (!write_ok) {
this.throw(
`ACCESS BLOCKED -- No write access to target project -- ${target_project_id}`
);
return false;
}
return true;
}
}