Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support free-form tags for scenes #68

Merged
merged 2 commits into from
Jul 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions source/server/auth/UserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function isAccessType(type :any) :type is AccessType{

export type AccessType = typeof AccessTypes[number];

export type AccessMap = {[id: `${number}`|string]:AccessType};

export const any_id = 1 as const;
export const default_id = 0 as const;
Expand Down
17 changes: 17 additions & 0 deletions source/server/migrations/005-collections.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
--------------------------------------------------------------------------------
-- Up
--------------------------------------------------------------------------------

CREATE TABLE tags(
tag_name TEXT NOT NULL COLLATE NOCASE,
fk_scene_id INTEGER NOT NULL,
FOREIGN KEY(fk_scene_id) REFERENCES scenes(scene_id),
UNIQUE(tag_name, fk_scene_id)
);

PRAGMA foreign_keys = ON;
--------------------------------------------------------------------------------
-- Down
--------------------------------------------------------------------------------

DROP TABLE tags;
5 changes: 5 additions & 0 deletions source/server/routes/api/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import handleGetStats from "./admin/stats/index.js";
import postScenes from "./scenes/post.js";
import patchScene from "./scenes/scene/patch.js";
import handleMailtest from "./admin/mailtest.js";
import getTags from "./tags/get.js";
import getTag from "./tags/tag/get.js";


const router = Router();
Expand Down Expand Up @@ -87,4 +89,7 @@ router.get("/scenes/:scene/files", wrap(getFiles));
router.get("/scenes/:scene/permissions", wrap(getPermissions));
router.patch("/scenes/:scene/permissions", canAdmin, bodyParser.json(), wrap(patchPermissions));


router.get("/tags", isUser, wrap(getTags));
router.get("/tags/:tag", isUser, wrap(getTag));
export default router;
23 changes: 21 additions & 2 deletions source/server/routes/api/v1/scenes/scene/patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ describe("PATCH /api/v1/scenes/:scene", function(){
await cleanIntegrationContext(this);
});

describe("rename a scene", function(){
it("get scene info", async function(){
describe("name", function(){
it("can rename", async function(){
await request(this.server).patch("/api/v1/scenes/foo")
.send({name: "foofoo"})
.expect(200);
Expand Down Expand Up @@ -50,4 +50,23 @@ describe("PATCH /api/v1/scenes/:scene", function(){
});
});

describe("tags", async function(){
it("add", async function(){
let r = await request(this.server).patch("/api/v1/scenes/foo")
.send({tags: ["foo", "bar"]})
.expect(200);

expect(r.body).to.have.property("tags").to.deep.equal(["foo", "bar"]);
})

it("trims tag names", async function(){
let r = await request(this.server).patch("/api/v1/scenes/foo")
.send({tags: [" foo"]})
.expect(200);

expect(r.body).to.have.property("tags").to.deep.equal(["foo"])
});

})

});
23 changes: 16 additions & 7 deletions source/server/routes/api/v1/scenes/scene/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,15 @@ import { Request, Response } from "express";
* @returns {Promise<void>}
*/
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;
let {scene: sceneName} = req.params;
let {name, tags} = 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){
let scene = await vfs.getScene(sceneName, user_id);
if(name && name !== sceneName){
try{
await vfs.renameScene(id, name);
await vfs.renameScene(scene.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`);
Expand All @@ -30,8 +29,18 @@ export default async function patchScene(req :Request, res :Response){
}
}
}
if(tags){
for(let tag of tags){
if (scene.tags.indexOf(tag) !== -1) continue;
await vfs.addTag(scene.id, tag.trim());
}
for(let ex_tag of scene.tags){
if(tags.indexOf(ex_tag) !== -1) continue;
await vfs.removeTag(scene.id, ex_tag);
}
}

return await vfs.getScene(id, user_id);
return await vfs.getScene(scene.id, user_id);
});
res.status(200).send(result);
};
11 changes: 11 additions & 0 deletions source/server/routes/api/v1/tags/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@

import { Request, Response } from "express";
import { getVfs } from "../../../../utils/locals.js";

export default async function getTags(req :Request, res :Response){

const vfs = getVfs(req);

const tags = await vfs.getTags();
res.status(200).send(tags);
}
15 changes: 15 additions & 0 deletions source/server/routes/api/v1/tags/tag/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@

import { Request, Response } from "express";
import { getUser, getVfs } from "../../../../../utils/locals.js";

export default async function getTag(req :Request, res :Response){
const requester = getUser(req);
const vfs = getVfs(req);
const {tag} = req.params;

const scenes = await Promise.all((await vfs.getTag(tag, requester.uid)).map(s=>{
return vfs.getScene(s, requester.uid);
}));

res.status(200).send(scenes);
}
2 changes: 1 addition & 1 deletion source/server/routes/scenes/get/file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe("GET /scenes/:scene/:filename(.*)", function(){
});

it("can't get a private scene's file (obfuscated as 404)", async function(){
let scene_id = await vfs.createScene("foo", {"0":"none", [user.uid]: "admin"});
let scene_id = await vfs.createScene("foo", {"0":"none", "1": "none", [user.uid]: "admin"});
await vfs.writeDoc("{}", scene_id, user.uid);
await vfs.writeFile(dataStream(), {scene: "foo", mime:"model/gltf-binary", name: "models/foo.glb", user_id: user.uid});

Expand Down
58 changes: 43 additions & 15 deletions source/server/vfs/Scenes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AccessType, AccessTypes } from "../auth/UserManager.js";
import { AccessMap, AccessType, AccessTypes } from "../auth/UserManager.js";
import config from "../utils/config.js";
import { BadRequestError, ConflictError, NotFoundError } from "../utils/errors.js";
import { Uid } from "../utils/uid.js";
Expand All @@ -10,9 +10,9 @@ export default abstract class ScenesVfs extends BaseVfs{

async createScene(name :string):Promise<number>
async createScene(name :string, author_id :number):Promise<number>
async createScene(name :string, permissions:Record<string,AccessType>):Promise<number>
async createScene(name :string, perms ?:Record<string,AccessType>|number) :Promise<number>{
let permissions :Record<string,AccessType> = (typeof perms === "object")? perms : {};
async createScene(name :string, permissions:AccessMap):Promise<number>
async createScene(name :string, perms ?:AccessMap|number) :Promise<number>{
let permissions :AccessMap = (typeof perms === "object")? perms : {};
//Always provide permissions for default user
permissions['0'] ??= (config.public?"read":"none");
permissions['1'] ??= "read";
Expand Down Expand Up @@ -88,6 +88,23 @@ export default abstract class ScenesVfs extends BaseVfs{
if(!r?.changes) throw new NotFoundError(`no scene found with id: ${$scene_id}`);
}

/**
* Reusable fragment to check if a user has the required access level for an operation on a scene.
* Most permission checks are done outside of this module in route middlewares,
* but we sometimes need to check for permissions to filter list results
* @param user_id User_id, to detect "default" special case
* @param accessMin Minimum expected acccess level, defaults to read
* @returns
*/
static _fragUserCanAccessScene(user_id :number, accessMin:AccessType = "read"){
return `COALESCE(
json_extract(scenes.access, '$.' || $user_id),
${(0 < user_id)? `json_extract(scenes.access, '$.1'),`:""}
json_extract(scenes.access, '$.0')
) IN (${ AccessTypes.slice(AccessTypes.indexOf(accessMin)).map(s=>`'${s}'`).join(", ") })
`;
}

/**
* get all scenes, including archvied scenes Generally not used outside of tests and internal routines
*/
Expand Down Expand Up @@ -188,6 +205,7 @@ export default abstract class ScenesVfs extends BaseVfs{
SELECT username FROM users WHERE fk_author_id = user_id
), "default") AS author,
json_extract(thumb.value, '$.uri') AS thumb,
tags.names AS tags,
json_object(
${(typeof user_id === "number" && 0 < user_id)? `
"user", IFNULL(json_extract(scenes.access, '$.' || $user_id), "none"),
Expand All @@ -197,17 +215,17 @@ export default abstract class ScenesVfs extends BaseVfs{
) AS access

FROM scenes
LEFT JOIN last_docs AS document ON fk_scene_id = scene_id
LEFT JOIN last_docs AS document ON document.fk_scene_id = scene_id
LEFT JOIN json_tree(document.metas) AS thumb ON thumb.fullkey LIKE "$[_].images[_]" AND json_extract(thumb.value, '$.quality') = 'Thumb'

LEFT JOIN (
SELECT
json_group_array(tag_name) AS names,
fk_scene_id
FROM tags
GROUP BY fk_scene_id
) AS tags ON tags.fk_scene_id = scene_id
${with_filter? "WHERE true": ""}
${(typeof user_id === "number")? `AND
COALESCE(
json_extract(scenes.access, '$.' || $user_id),
${(0 < user_id)? `json_extract(scenes.access, '$.1'),`:""}
json_extract(scenes.access, '$.0')
) IN (${ AccessTypes.slice(2).map(s=>`'${s}'`).join(", ") })
`:""}
${typeof user_id === "number"? `AND ${ScenesVfs._fragUserCanAccessScene(user_id, "read")}`:""}
${(access?.length)? `AND json_extract(scenes.access, '$.' || $user_id) IN (${ access.map(s=>`'${s}'`).join(", ") })`:""}
${likeness}

Expand All @@ -222,6 +240,7 @@ export default abstract class ScenesVfs extends BaseVfs{
})).map(({ctime, mtime, id, access, ...m})=>({
...m,
id,
tags: m.tags ? JSON.parse(m.tags): [],
access: JSON.parse(access),
ctime: BaseVfs.toDate(ctime),
mtime: BaseVfs.toDate(mtime),
Expand All @@ -245,21 +264,30 @@ export default abstract class ScenesVfs extends BaseVfs{
SELECT username FROM users WHERE user_id = fk_author_id
), 'default') AS author,
json_extract(thumb.value, '$.uri') AS thumb,
tags.names AS tags,
json_object(
${(user_id)? `"user", IFNULL(json_extract(scenes.access, '$.' || $user_id), "none"),`: ``}
"any", json_extract(scenes.access, '$.1'),
"default", json_extract(scenes.access, '$.0')
) AS access
FROM scenes
LEFT JOIN documents ON fk_scene_id = scene_id
LEFT JOIN json_tree(documents.data, "$.metas") AS thumb ON thumb.fullkey LIKE "$[_].images[_]" AND json_extract(thumb.value, '$.quality') = 'Thumb'
LEFT JOIN documents ON documents.fk_scene_id = scene_id
LEFT JOIN json_tree(documents.data, "$.metas") AS thumb ON thumb.fullkey LIKE "$[_].images[_]" AND json_extract(thumb.value, '$.quality') = 'Thumb'
LEFT JOIN (
SELECT
json_group_array(tag_name) AS names,
fk_scene_id
FROM tags
GROUP BY fk_scene_id
) AS tags ON tags.fk_scene_id = scene_id
WHERE ${key} = $value
ORDER BY generation DESC
LIMIT 1
`, {$value: nameOrId, $user_id: user_id? user_id.toString(10): undefined});
if(!r|| !r.name) throw new NotFoundError(`No scene found with ${key}: ${nameOrId}`);
return {
...r,
tags: r.tags ? JSON.parse(r.tags): [],
access: JSON.parse(r.access),
ctime: BaseVfs.toDate(r.ctime),
mtime: BaseVfs.toDate(r.mtime),
Expand Down
104 changes: 104 additions & 0 deletions source/server/vfs/Tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { NotFoundError } from "../utils/errors.js";
import BaseVfs from "./Base.js";
import ScenesVfs from "./Scenes.js";
import { Tag } from "./types.js";


export default abstract class TagsVfs extends BaseVfs{

/**
* Set tags for a scene. Does not check for permissions
* Returns true if a tag was added, false otherwise.
* Throws an error on invalid scene ID
*/
async addTag(scene_name :string, tag :string) :Promise<boolean>;
async addTag(scene_id :number, tag :string) :Promise<boolean>;
async addTag(scene :string|number, tag :string) :Promise<boolean>{
let match = ((typeof scene ==="number")?'$scene':`scene_id FROM scenes WHERE scene_name = $scene`);
try{
let r = await this.db.run(`
INSERT INTO tags
(tag_name, fk_scene_id)
SELECT $tag, ${match}
`, {
$tag: tag.toLowerCase(),
$scene: scene
});
if(!r.changes) throw new NotFoundError(`Can't find scene matching ${scene}`);
}catch(e:any){
if(e.code === "SQLITE_CONSTRAINT" && /FOREIGN KEY/.test(e.message)){
throw new NotFoundError(`Can't find scene matching ${scene}`);
}else if(e.code === "SQLITE_CONSTRAINT" && /UNIQUE constraint/.test(e.message)){
return false;
}
throw e;
}
return true;
}

/**
* Returns true if something was changed, false otherwise
*/
async removeTag(scene_name :string, tag :string): Promise<boolean>;
async removeTag(scene_id :number, tag: string): Promise<boolean>;
async removeTag(scene :number|string, tag :string):Promise<boolean>{
let match = ((typeof scene === "number")?`fk_scene_id = $scene`: `
fk_scene_id IN (
SELECT scene_id
FROM scenes
WHERE scene_name = $scene
)
`);
let r = await this.db.run(`
DELETE FROM tags
WHERE tag_name = $tag AND ${match}
`, {
$tag: tag,
$scene: scene
});
return !!r.changes;
}

async getTags(like ?:string):Promise<Tag[]>{
let where :string = like?`WHERE tag_name LIKE '%' || $like || '%'` :"";
return await this.db.all<Tag[]>(
`
SELECT
tag_name AS name,
COUNT(fk_scene_id) as size
FROM
tags,
scenes
ON fk_scene_id = scene_id
${where}
GROUP BY name
ORDER BY name ASC
`,
{$like: like}
);
}

/**
* Get all scenes that have this tag, regrdless of permissions
* @fixme the JOIN could be optimized away in this case
*/
async getTag(name :string):Promise<number[]>
/** Get all scenes that have this tag that this user can read */
async getTag(name :string, user_id :number):Promise<number[]>
async getTag(name :string, user_id ?:number):Promise<number[]>{

let scenes = await this.db.all<{scene_id:number}[]>(`
SELECT scene_id
FROM
tags,
scenes
ON fk_scene_id = scene_id
WHERE
tags.tag_name = $name
${typeof user_id === "number"?`AND ${ScenesVfs._fragUserCanAccessScene(user_id, "read")}`:""}
ORDER BY scene_name ASC
`, {$name: name, $user_id: user_id?.toString(10)});

return scenes.map(s=>s.scene_id);
}
}
Loading
Loading