From 3bc43a106307a78ff799a89849074c1593d8c5b9 Mon Sep 17 00:00:00 2001 From: Sebastien Dumetz Date: Mon, 25 Sep 2023 17:00:04 +0200 Subject: [PATCH] Rename scenes fix #37 (#41) * add some comments and remove a useless log * add PATCH route on scenes * fix custom events that were not cancellable * add UI to rename scenes --- source/server/auth/UserManager.ts | 8 +++ source/server/routes/api/v1/index.ts | 3 ++ .../server/routes/api/v1/scenes/scene/get.ts | 1 - .../routes/api/v1/scenes/scene/patch.test.ts | 53 +++++++++++++++++++ .../routes/api/v1/scenes/scene/patch.ts | 37 +++++++++++++ .../server/routes/api/v1/scenes/scene/post.ts | 8 ++- source/ui/screens/SceneHistory.ts | 50 +++++++++++++++-- source/ui/state/router.ts | 5 +- source/ui/state/strings.ts | 8 +++ 9 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 source/server/routes/api/v1/scenes/scene/patch.test.ts create mode 100644 source/server/routes/api/v1/scenes/scene/patch.ts diff --git a/source/server/auth/UserManager.ts b/source/server/auth/UserManager.ts index 6c245a2e..8c667ee6 100644 --- a/source/server/auth/UserManager.ts +++ b/source/server/auth/UserManager.ts @@ -281,6 +281,14 @@ export default class UserManager { if(!r || !r.changes) throw new NotFoundError(`No user to delete with uid ${uid}`); } + /** + * patches permissions on a scene for a given user. + * Special cases for "any" and "default" users. + * Usernames are converted to IDs before being used. + * @param scene + * @param username + * @param role + */ async grant(scene :string, username :string, role :AccessType){ if(!isAccessType(role)) throw new BadRequestError(`Bad access type requested : ${role}`); let r = await this.db.run(` diff --git a/source/server/routes/api/v1/index.ts b/source/server/routes/api/v1/index.ts index e3ba1efe..80a508d6 100644 --- a/source/server/routes/api/v1/index.ts +++ b/source/server/routes/api/v1/index.ts @@ -23,6 +23,7 @@ import { handlePatchUser } from "./users/uid/patch"; import { postSceneHistory } from "./scenes/scene/history/post"; import handleGetStats from "./stats"; import postScenes from "./scenes/post"; +import patchScene from "./scenes/scene/patch"; @@ -64,7 +65,9 @@ router.patch("/users/:uid", bodyParser.json(), wrap(handlePatchUser)); router.get("/scenes", wrap(getScenes)); router.post("/scenes", isAdministrator, wrap(postScenes)); + router.post("/scenes/:scene", isUser, wrap(postScene)); +router.patch("/scenes/:scene", canAdmin, bodyParser.json(), wrap(patchScene)); router.use("/scenes/:scene", canRead); router.get("/scenes/:scene/history", wrap(getSceneHistory)); diff --git a/source/server/routes/api/v1/scenes/scene/get.ts b/source/server/routes/api/v1/scenes/scene/get.ts index d63005d0..8b2aade6 100644 --- a/source/server/routes/api/v1/scenes/scene/get.ts +++ b/source/server/routes/api/v1/scenes/scene/get.ts @@ -16,7 +16,6 @@ export default async function getScene(req :Request, res :Response){ await wrapFormat(res, { "application/json": async ()=>{ let requester = getUserId(req); - console.log("Get scene : ", scene, requester); let data = await vfs.getScene(scene, requester); res.status(200).send(data); }, diff --git a/source/server/routes/api/v1/scenes/scene/patch.test.ts b/source/server/routes/api/v1/scenes/scene/patch.test.ts new file mode 100644 index 00000000..d5a4f5e2 --- /dev/null +++ b/source/server/routes/api/v1/scenes/scene/patch.test.ts @@ -0,0 +1,53 @@ + + +import request from "supertest"; + +import Vfs from "../../../../../vfs"; +import UserManager from "../../../../../auth/UserManager"; + +describe("PATCH /api/v1/scenes/:scene", function(){ + let vfs:Vfs, userManager:UserManager, ids :number[]; + this.beforeEach(async function(){ + let locals = await createIntegrationContext(this); + vfs = locals.vfs; + userManager = locals.userManager; + ids = await Promise.all([ + vfs.createScene("foo", {0:"admin"}), + vfs.createScene("bar", {0:"admin"}), + ]); + }); + + this.afterEach(async function(){ + await cleanIntegrationContext(this); + }); + + describe("rename a scene", function(){ + it("get scene info", async function(){ + await request(this.server).patch("/api/v1/scenes/foo") + .send({name: "foofoo"}) + .expect(200); + }); + + it("forces unique names", async function(){ + await request(this.server).patch("/api/v1/scenes/foo") + .send({name: "bar"}) + .expect(409); + }) + + it("is admin-protected", async function(){ + await userManager.grant("foo", "any", "write"); + await userManager.grant("foo", "default", "write"); + await request(this.server).patch("/api/v1/scenes/foo") + .send({name: "foofoo"}) + .expect(401); + }); + + it("is access-protected (obfuscated as 404)", async function(){ + await userManager.grant("foo", "default", "none"); + await request(this.server).patch("/api/v1/scenes/foo") + .send({name: "foofoo"}) + .expect(404); + }); + }); + +}); diff --git a/source/server/routes/api/v1/scenes/scene/patch.ts b/source/server/routes/api/v1/scenes/scene/patch.ts new file mode 100644 index 00000000..75d68331 --- /dev/null +++ b/source/server/routes/api/v1/scenes/scene/patch.ts @@ -0,0 +1,37 @@ + +import { ConflictError } from "../../../../../utils/errors"; +import { getUserId, getVfs } from "../../../../../utils/locals"; +import { Request, Response } from "express"; + + + +/** + * Patches a scene's properties + * @param req + * @param res + * @returns {Promise} + */ +export default async function patchScene(req :Request, res :Response){ + let vfs = getVfs(req); + let user_id = getUserId(req); + let {scene} = req.params; + let {name} = req.body; + //Ensure all or none of the changes are comitted + let result = await getVfs(req).isolate(async (vfs)=>{ + let {id} = await vfs.getScene(scene, user_id); + if(name && name !== scene){ + try{ + await vfs.renameScene(id, name); + }catch(e:any){ + if(e.code == "SQLITE_CONSTRAINT" && /UNIQUE constraint failed: scenes.scene_name/.test(e.message)){ + throw new ConflictError(`A scene named ${name} already exists`); + }else{ + throw e; + } + } + } + + return await vfs.getScene(id, user_id); + }); + res.status(200).send(result); +}; diff --git a/source/server/routes/api/v1/scenes/scene/post.ts b/source/server/routes/api/v1/scenes/scene/post.ts index b591086d..4f64961d 100644 --- a/source/server/routes/api/v1/scenes/scene/post.ts +++ b/source/server/routes/api/v1/scenes/scene/post.ts @@ -4,7 +4,13 @@ import { Request, Response } from "express"; import { parse_glb } from "../../../../../utils/glTF"; - +/** + * Creates a new default document for a scene + * uses data embedded in the glb to fill the document where possible + * @param scene + * @param filepath + * @returns + */ async function getDocument(scene:string, filepath:string){ let {default:orig} = await import("../../../../../utils/documents/default.svx.json"); //dumb inefficient Deep copy because we want to mutate the doc in-place diff --git a/source/ui/screens/SceneHistory.ts b/source/ui/screens/SceneHistory.ts index c6af4768..238a6741 100644 --- a/source/ui/screens/SceneHistory.ts +++ b/source/ui/screens/SceneHistory.ts @@ -12,6 +12,7 @@ import { nothing } from "lit-html"; import i18n from "../state/translate"; import {getLogin} from "../state/auth"; import { navigate } from "../state/router"; +import Modal from "../composants/Modal"; const AccessTypes = [ @@ -163,7 +164,7 @@ class SceneVersion{

${articles.size} article${(1 < articles.size?"s":"")}

${this.t("ui.editScene")} - ${this.t("ui.viewScene")} + ${this.t("ui.viewScene")} ${this.t("ui.downloadScene")}
@@ -175,8 +176,9 @@ class SceneVersion{
${this.renderHistory()}
- ${getLogin()?.isAdministrator? html`
-
+ ${getLogin()?.isAdministrator? html`
+
+
`:null}
`; } @@ -322,4 +324,46 @@ class SceneVersion{ Notification.show(`Failed to remove ${this.scene} : ${e.message}`); }); } + + onRename = ()=>{ + const onRenameSubmit = (ev)=>{ + ev.preventDefault(); + let name = (Modal.Instance.shadowRoot.getElementById("sceneRenameInput") as HTMLInputElement).value; + Modal.close(); + Modal.show({ + header: this.t("ui.renameScene"), + body: html`
`, + }); + fetch(`/api/v1/scenes/${encodeURIComponent(this.scene)}`, { + method:"PATCH", + headers:{"Content-Type":"application/json"}, + body: JSON.stringify({name}) + }).then((r)=>{ + if(r.ok){ + Notification.show("Renamed "+this.scene+" to "+name, "info", 1600); + navigate(this, `/ui/scenes/${encodeURIComponent(name)}/`); + }else{ + throw new Error(`[${r.status}] ${r.statusText}`); + } + }).catch((e)=>{ + console.error(e); + Notification.show(`Failed to rename ${this.scene} : ${e.message}`); + }).finally(()=>{ + setTimeout(()=>{ + Modal.close(); + }, 500); + }); + } + + Modal.show({ + header: this.t("ui.renameScene"), + body: html`
+
+ +
+
`, + buttons: html``, + }); + + } } \ No newline at end of file diff --git a/source/ui/state/router.ts b/source/ui/state/router.ts index 77b1e3f1..1847aea8 100644 --- a/source/ui/state/router.ts +++ b/source/ui/state/router.ts @@ -85,6 +85,7 @@ export function router>(baseClass:T) : T & Con if(url.hostname != window.location.hostname) return window.location.href = url.toString(); if(!this.inPath(url.pathname) && this.path != "/") return; //Return to bubble up the stack + console.debug("Handle navigation event", url.toString()); ev.stopPropagation(); //Handle the route change ev.preventDefault(); @@ -133,10 +134,12 @@ export function navigate(that :HTMLElement,href ?:string|URL, queries?:Record