Skip to content

Commit

Permalink
server-side tags support
Browse files Browse the repository at this point in the history
  • Loading branch information
sdumetz committed Jul 5, 2024
1 parent 140c83e commit 62614f5
Show file tree
Hide file tree
Showing 13 changed files with 384 additions and 27 deletions.
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;
15 changes: 13 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,15 @@ 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"]);
})

})

});
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);
}
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);
}
}
5 changes: 3 additions & 2 deletions source/server/vfs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import DocsVfs from "./Docs.js";
import ScenesVfs from "./Scenes.js";
import CleanVfs from "./Clean.js";
import StatsVfs from "./Stats.js";
import TagsVfs from "./Tags.js";

export * from "./types.js";

Expand Down Expand Up @@ -46,8 +47,8 @@ class Vfs extends BaseVfs{
}
}

interface Vfs extends FilesVfs, DocsVfs, ScenesVfs, StatsVfs, CleanVfs {};
applyMixins(Vfs, [FilesVfs, DocsVfs, ScenesVfs, StatsVfs, CleanVfs]);
interface Vfs extends FilesVfs, DocsVfs, ScenesVfs, StatsVfs, TagsVfs, CleanVfs {};
applyMixins(Vfs, [FilesVfs, DocsVfs, ScenesVfs, StatsVfs, TagsVfs, CleanVfs]);

export default Vfs;

Expand Down
Loading

0 comments on commit 62614f5

Please sign in to comment.