diff --git a/src/packages/next/lib/api/schema/projects/delete.ts b/src/packages/next/lib/api/schema/projects/delete.ts new file mode 100644 index 0000000000..7f16cd12a1 --- /dev/null +++ b/src/packages/next/lib/api/schema/projects/delete.ts @@ -0,0 +1,28 @@ +import { z } from "../../framework"; + +import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../common"; + +import { ProjectIdSchema } from "./common"; + +// OpenAPI spec +// +export const DeleteProjectInputSchema = z + .object({ + project_id: ProjectIdSchema, + }) + .describe( + `Deletes a specific project. This causes three operations to occur in succession. + Firstly, all project licenses associated with the project are removed. Next, the + project is stopped. Finally, the project's \`delete\` flag in the database is + set, which removes it from the user interface. This operation may be reversed by + restoring the project via the API, with the proviso that all information about + applied project licenses is lost in the delete operation.`, + ); + +export const DeleteProjectOutputSchema = z.union([ + FailedAPIOperationSchema, + OkAPIOperationSchema, +]); + +export type DeleteProjectInput = z.infer; +export type DeleteProjectOutput = z.infer; diff --git a/src/packages/next/lib/api/schema/projects/restore.ts b/src/packages/next/lib/api/schema/projects/restore.ts new file mode 100644 index 0000000000..5e1c9c7b2f --- /dev/null +++ b/src/packages/next/lib/api/schema/projects/restore.ts @@ -0,0 +1,26 @@ +import { z } from "../../framework"; + +import { FailedAPIOperationSchema, OkAPIOperationSchema } from "../common"; + +import { ProjectIdSchema } from "./common"; + +// OpenAPI spec +// +export const RestoreProjectInputSchema = z + .object({ + project_id: ProjectIdSchema, + }) + .describe( + `Restores a specific project from its deleted state, which clears the project's + \`delete\` flag in the database and restores it to the user interface. Note that any + previously applied project licenses must be re-applied to the project upon + restoration.`, + ); + +export const RestoreProjectOutputSchema = z.union([ + FailedAPIOperationSchema, + OkAPIOperationSchema, +]); + +export type RestoreProjectInput = z.infer; +export type RestoreProjectOutput = z.infer; diff --git a/src/packages/next/pages/api/v2/projects/delete.ts b/src/packages/next/pages/api/v2/projects/delete.ts new file mode 100644 index 0000000000..85c70888c2 --- /dev/null +++ b/src/packages/next/pages/api/v2/projects/delete.ts @@ -0,0 +1,88 @@ +/* +API endpoint to delete a project, which sets the "delete" flag to `true` in the database. +*/ +import isCollaborator from "@cocalc/server/projects/is-collaborator"; +import userIsInGroup from "@cocalc/server/accounts/is-in-group"; +import removeAllLicensesFromProject from "@cocalc/server/licenses/remove-all-from-project"; +import { getProject } from "@cocalc/server/projects/control"; +import userQuery from "@cocalc/database/user-query"; +import { isValidUUID } from "@cocalc/util/misc"; + +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; +import { apiRoute, apiRouteOperation } from "lib/api"; +import { OkStatus } from "lib/api/status"; +import { + DeleteProjectInputSchema, + DeleteProjectOutputSchema, +} from "lib/api/schema/projects/delete"; + +async function handle(req, res) { + const { project_id } = getParams(req); + const account_id = await getAccountId(req); + + try { + if (!isValidUUID(project_id)) { + throw Error("project_id must be a valid uuid"); + } + if (!account_id) { + throw Error("must be signed in"); + } + + // If client is not an administrator, they must be a project collaborator in order to + // delete a project. + if ( + !(await userIsInGroup(account_id, "admin")) && + !(await isCollaborator({ account_id, project_id })) + ) { + throw Error("must be an owner to delete a project"); + } + + // Remove all project licenses + // + await removeAllLicensesFromProject({ project_id }); + + // Stop project + // + const project = getProject(project_id); + await project.stop(); + + // Set "deleted" flag. We do this last to ensure that the project is not consuming any + // resources while it is in the deleted state. + // + await userQuery({ + account_id, + query: { + projects: { + project_id, + deleted: true, + }, + }, + }); + + res.json(OkStatus); + } catch (err) { + res.json({ error: err.message }); + } +} + +export default apiRoute({ + deleteProject: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects", "Admin"], + }, + }) + .input({ + contentType: "application/json", + body: DeleteProjectInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: DeleteProjectOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/next/pages/api/v2/projects/get.ts b/src/packages/next/pages/api/v2/projects/get.ts index 64b9253f1f..c2aedaf828 100644 --- a/src/packages/next/pages/api/v2/projects/get.ts +++ b/src/packages/next/pages/api/v2/projects/get.ts @@ -40,7 +40,7 @@ async function handle(req, res) { } export default apiRoute({ - get: apiRouteOperation({ + getProject: apiRouteOperation({ method: "POST", openApiOperation: { tags: ["Projects", "Admin"], diff --git a/src/packages/next/pages/api/v2/projects/restore.ts b/src/packages/next/pages/api/v2/projects/restore.ts new file mode 100644 index 0000000000..f8b3e67336 --- /dev/null +++ b/src/packages/next/pages/api/v2/projects/restore.ts @@ -0,0 +1,75 @@ +/* +API endpoint to restore a deleted a project, which sets the "delete" flag to `false` in +the database. +*/ +import { isValidUUID } from "@cocalc/util/misc"; +import isCollaborator from "@cocalc/server/projects/is-collaborator"; +import userIsInGroup from "@cocalc/server/accounts/is-in-group"; +import userQuery from "@cocalc/database/user-query"; + +import getAccountId from "lib/account/get-account"; +import getParams from "lib/api/get-params"; +import { apiRoute, apiRouteOperation } from "lib/api"; +import { OkStatus } from "lib/api/status"; +import { + RestoreProjectInputSchema, + RestoreProjectOutputSchema, +} from "lib/api/schema/projects/restore"; + +async function handle(req, res) { + const { project_id } = getParams(req); + const account_id = await getAccountId(req); + + try { + if (!isValidUUID(project_id)) { + throw Error("project_id must be a valid uuid"); + } + if (!account_id) { + throw Error("must be signed in"); + } + + // If client is not an administrator, they must be a project collaborator in order to + // restore a project. + if ( + !(await userIsInGroup(account_id, "admin")) && + !(await isCollaborator({ account_id, project_id })) + ) { + throw Error("must be an owner to restore a project"); + } + + await userQuery({ + account_id, + query: { + projects: { + project_id, + deleted: false, + }, + }, + }); + + res.json(OkStatus); + } catch (err) { + res.json({ error: err.message }); + } +} + +export default apiRoute({ + restoreProject: apiRouteOperation({ + method: "POST", + openApiOperation: { + tags: ["Projects", "Admin"], + }, + }) + .input({ + contentType: "application/json", + body: RestoreProjectInputSchema, + }) + .outputs([ + { + status: 200, + contentType: "application/json", + body: RestoreProjectOutputSchema, + }, + ]) + .handler(handle), +}); diff --git a/src/packages/next/pages/api/v2/projects/start.ts b/src/packages/next/pages/api/v2/projects/start.ts index 86848e09a1..0098e1ffc8 100644 --- a/src/packages/next/pages/api/v2/projects/start.ts +++ b/src/packages/next/pages/api/v2/projects/start.ts @@ -39,7 +39,7 @@ async function handle(req, res) { } export default apiRoute({ - start: apiRouteOperation({ + startProject: apiRouteOperation({ method: "POST", openApiOperation: { tags: ["Projects"], diff --git a/src/packages/next/pages/api/v2/projects/stop.ts b/src/packages/next/pages/api/v2/projects/stop.ts index d1aeb84c84..baafd74132 100644 --- a/src/packages/next/pages/api/v2/projects/stop.ts +++ b/src/packages/next/pages/api/v2/projects/stop.ts @@ -39,7 +39,7 @@ async function handle(req, res) { } export default apiRoute({ - stop: apiRouteOperation({ + stopProject: apiRouteOperation({ method: "POST", openApiOperation: { tags: ["Projects"], diff --git a/src/packages/next/pages/api/v2/projects/update.ts b/src/packages/next/pages/api/v2/projects/update.ts index 12f3c0ce11..2a52cccc43 100644 --- a/src/packages/next/pages/api/v2/projects/update.ts +++ b/src/packages/next/pages/api/v2/projects/update.ts @@ -56,7 +56,7 @@ async function get(req) { } export default apiRoute({ - update: apiRouteOperation({ + updateProject: apiRouteOperation({ method: "POST", openApiOperation: { tags: ["Projects", "Admin"], diff --git a/src/packages/server/licenses/remove-all-from-project.ts b/src/packages/server/licenses/remove-all-from-project.ts new file mode 100644 index 0000000000..a8b12c70d4 --- /dev/null +++ b/src/packages/server/licenses/remove-all-from-project.ts @@ -0,0 +1,22 @@ +import getPool, { PoolClient } from "@cocalc/database/pool"; + +interface Options { + project_id: string; + client?: PoolClient; +} + +// Set the site_license field of the entry in the PostgreSQL projects table with given +// project_id to {}. The site_license field is JSONB. +// +export default async function removeAllLicensesFromProject({ + project_id, + client, +}: Options) { + const pool = client ?? getPool(); + await pool.query( + ` + UPDATE projects SET site_license = '{}'::JSONB WHERE project_id = $1 +`, + [project_id], + ); +}