60 lines
5.0 KiB
JavaScript
60 lines
5.0 KiB
JavaScript
import { z } from 'zod';
|
|
import { BookmarkSchema,BookmarkItemsSchema } from '../../packages/core/bookmarks.mjs';
|
|
import { PolicySchema,destinationAllowed } from '../../packages/core/policy.mjs';
|
|
import { settingsSectionAllowed } from '../../packages/core/managed-settings.mjs';
|
|
const mutation=z.discriminatedUnion('action',[
|
|
z.object({action:z.literal('add'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),bookmark:BookmarkSchema}).strict(),
|
|
z.object({action:z.literal('update'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),bookmark:BookmarkSchema}).strict(),
|
|
z.object({action:z.literal('delete'),expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),id:BookmarkSchema.shape.id}).strict()
|
|
]);
|
|
export function globalBookmarksValid(store,policy){
|
|
if(policy.browser.bookmarks.personal.revision||policy.browser.bookmarks.personal.items.length)return false;
|
|
const managed=policy.browser.bookmarks.managed.map(b=>b.id);
|
|
if(!managed.length)return true;
|
|
// Let SQLite examine IDs without copying every employee's bookmark collection
|
|
// into the management process on each company-policy publication.
|
|
return !store.db.prepare(`SELECT 1 AS found FROM bookmarks AS b, json_each(b.items) AS item WHERE json_extract(item.value,'$.id') IN (${managed.map(()=>'?').join(',')}) LIMIT 1`).get(...managed);
|
|
}
|
|
export function installBookmarkRoutes(app,store,device,admin){
|
|
function active(id){return store.db.prepare('SELECT id FROM devices WHERE id=? AND revoked=0').get(id);}
|
|
function commit(id,state,items,policy,actor,action){
|
|
policy=PolicySchema.parse(policy);
|
|
store.db.exec('BEGIN IMMEDIATE');
|
|
try{
|
|
// Requests are synchronous: version and revision comparisons cannot interleave before BEGIN.
|
|
store.db.prepare('INSERT INTO bookmarks VALUES(?,?,?) ON CONFLICT(device_id) DO UPDATE SET revision=excluded.revision,items=excluded.items').run(id,state.revision+1,JSON.stringify(items));
|
|
|
|
store.event(id,'bookmarks.changed',{actor,action,count:items.length,revision:state.revision+1,policyVersion:policy.version});store.db.exec('COMMIT');
|
|
}catch(error){store.db.exec('ROLLBACK');throw error;}
|
|
return {revision:state.revision+1,items,version:policy.version};
|
|
}
|
|
app.post('/api/device/bookmarks',device,(req,res)=>{
|
|
const b=mutation.parse(req.body),p=store.effectivePolicy(req.device.id),s=store.personalBookmarks(req.device.id),rules=p.browser.bookmarks;
|
|
if(b.policyVersion!==p.version||b.expectedRevision!==s.revision)return res.status(409).json({error:'Bookmark or policy version conflict'});
|
|
if(Date.parse(p.expiresAt)<=Date.now()||!settingsSectionAllowed(p,'library')||!rules[{add:'allowUserAdd',update:'allowUserEdit',delete:'allowUserDelete'}[b.action]])return res.status(403).json({error:'Bookmarks are managed by your administrator'});
|
|
const id=b.bookmark?.id||b.id;
|
|
if(rules.managed.some(i=>i.id===id))return res.status(403).json({error:'Company bookmarks cannot be modified'});
|
|
if(b.bookmark&&!destinationAllowed(b.bookmark.url,p))return res.status(403).json({error:'Bookmark destination blocked by policy'});
|
|
const index=s.items.findIndex(i=>i.id===id),items=structuredClone(s.items);
|
|
if(b.action==='add'){if(index!==-1)return res.status(409).json({error:'Bookmark ID already exists'});items.push(b.bookmark);}
|
|
else {if(index===-1)return res.status(404).json({error:'Bookmark not found'});if(b.action==='delete')items.splice(index,1);else items[index]=b.bookmark;}
|
|
BookmarkItemsSchema.parse(items);res.json(commit(req.device.id,s,items,p,`device:${req.device.id}`,b.action));
|
|
});
|
|
app.get('/api/admin/devices/:id/bookmarks',admin,(req,res)=>{
|
|
if(!active(req.params.id))return res.status(404).json({error:'Active device not found'});
|
|
const p=store.effectivePolicy(req.params.id);if(!p.browser.bookmarks.adminReadUserBookmarks)return res.status(403).json({error:'Remote bookmark review disabled by policy'});
|
|
const s=store.personalBookmarks(req.params.id);store.event(req.params.id,'bookmarks.reviewed',{actor:req.principal.sub,count:s.items.length,revision:s.revision});res.json({...s,version:p.version});
|
|
});
|
|
app.put('/api/admin/devices/:id/bookmarks',admin,(req,res)=>{
|
|
const b=z.object({expectedRevision:z.number().int().nonnegative(),policyVersion:z.number().int().positive(),items:BookmarkItemsSchema}).strict().parse(req.body);
|
|
if(!active(req.params.id))return res.status(404).json({error:'Active device not found'});
|
|
const p=store.effectivePolicy(req.params.id),s=store.personalBookmarks(req.params.id);
|
|
if(!p.browser.bookmarks.adminReadUserBookmarks)return res.status(403).json({error:'Remote bookmark review disabled by policy'});
|
|
if(b.policyVersion!==p.version||b.expectedRevision!==s.revision)return res.status(409).json({error:'Bookmark or policy version conflict'});
|
|
if(b.items.some(i=>p.browser.bookmarks.managed.some(m=>m.id===i.id)))return res.status(400).json({error:'Personal and company bookmark IDs conflict'});
|
|
res.json(commit(req.params.id,s,b.items,p,req.principal.sub,'administrator-update'));
|
|
});
|
|
}
|
|
|
|
|