From 4363df175d50e0f9729381ac2ba9b37a3c3a966d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Tue, 9 Dec 2025 17:57:15 -0500 Subject: [PATCH] enh: read only notes --- backend/open_webui/models/notes.py | 88 ++++++++++- backend/open_webui/routers/notes.py | 15 +- src/lib/apis/notes/index.ts | 5 + src/lib/components/notes/NoteEditor.svelte | 171 +++++++++++---------- src/lib/components/notes/Notes.svelte | 57 +++++-- 5 files changed, 236 insertions(+), 100 deletions(-) diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index 0942ad0140..d61094b6ff 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -96,11 +96,86 @@ class NoteTable: def _has_permission(self, db, query, filter: dict, permission: str = "read"): group_ids = filter.get("group_ids", []) user_id = filter.get("user_id") - dialect_name = db.bind.dialect.name - # Public access conditions = [] + + # Handle read_only permission separately + if permission == "read_only": + # For read_only, we want items where: + # 1. User has explicit read permission (via groups or user-level) + # 2. BUT does NOT have write permission + # 3. Public items are NOT considered read_only + + read_conditions = [] + + # Group-level read permission + if group_ids: + group_read_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_read_conditions.append( + Note.access_control["read"]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_read_conditions.append( + cast( + Note.access_control["read"]["group_ids"], + JSONB, + ).contains([gid]) + ) + + if group_read_conditions: + read_conditions.append(or_(*group_read_conditions)) + + # Combine read conditions + if read_conditions: + has_read = or_(*read_conditions) + else: + # If no read conditions, return empty result + return query.filter(False) + + # Now exclude items where user has write permission + write_exclusions = [] + + # Exclude items owned by user (they have implicit write) + if user_id: + write_exclusions.append(Note.user_id != user_id) + + # Exclude items where user has explicit write permission via groups + if group_ids: + group_write_conditions = [] + for gid in group_ids: + if dialect_name == "sqlite": + group_write_conditions.append( + Note.access_control["write"]["group_ids"].contains([gid]) + ) + elif dialect_name == "postgresql": + group_write_conditions.append( + cast( + Note.access_control["write"]["group_ids"], + JSONB, + ).contains([gid]) + ) + + if group_write_conditions: + # User should NOT have write permission + write_exclusions.append(~or_(*group_write_conditions)) + + # Exclude public items (items without access_control) + write_exclusions.append(Note.access_control.isnot(None)) + write_exclusions.append(cast(Note.access_control, String) != "null") + + # Combine: has read AND does not have write AND not public + if write_exclusions: + query = query.filter(and_(has_read, *write_exclusions)) + else: + query = query.filter(has_read) + + return query + + # Original logic for other permissions (read, write, etc.) + # Public access conditions if group_ids or user_id: conditions.extend( [ @@ -109,7 +184,7 @@ class NoteTable: ] ) - # User-level permission + # User-level permission (owner has all permissions) if user_id: conditions.append(Note.user_id == user_id) @@ -191,11 +266,16 @@ class NoteTable: query = query.filter(Note.user_id != user_id) # Apply access control filtering + if "permission" in filter: + permission = filter["permission"] + else: + permission = "write" + query = self._has_permission( db, query, filter, - permission="write", + permission=permission, ) order_by = filter.get("order_by") diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 6cbbc4eaf3..cf8eb6112f 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -84,6 +84,7 @@ async def search_notes( request: Request, query: Optional[str] = None, view_option: Optional[str] = None, + permission: Optional[str] = None, order_by: Optional[str] = None, direction: Optional[str] = None, page: Optional[int] = 1, @@ -108,6 +109,8 @@ async def search_notes( filter["query"] = query if view_option: filter["view_option"] = view_option + if permission: + filter["permission"] = permission if order_by: filter["order_by"] = order_by if direction: @@ -156,7 +159,11 @@ async def create_new_note( ############################ -@router.get("/{id}", response_model=Optional[NoteModel]) +class NoteResponse(NoteModel): + write_access: bool = False + + +@router.get("/{id}", response_model=Optional[NoteResponse]) async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): if user.role != "admin" and not has_permission( user.id, "features.notes", request.app.state.config.USER_PERMISSIONS @@ -180,7 +187,11 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() ) - return note + write_access = has_access( + user.id, type="write", access_control=note.access_control, strict=False + ) + + return NoteResponse(**note.model_dump(), write_access=write_access) ############################ diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index 945b8f2261..55f9427e0d 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -95,6 +95,7 @@ export const searchNotes = async ( token: string = '', query: string | null = null, viewOption: string | null = null, + permission: string | null = null, sortKey: string | null = null, page: number | null = null ) => { @@ -109,6 +110,10 @@ export const searchNotes = async ( searchParams.append('view_option', viewOption); } + if (permission !== null) { + searchParams.append('permission', permission); + } + if (sortKey !== null) { searchParams.append('order_by', sortKey); } diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index f49d8bb7d0..069d717a87 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -157,6 +157,16 @@ if (res) { note = res; files = res.data.files || []; + + if (note?.write_access) { + $socket?.emit('join-note', { + note_id: id, + auth: { + token: localStorage.token + } + }); + $socket?.on('note-events', noteEventHandler); + } } else { goto('/'); return; @@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, onMount(async () => { await tick(); - $socket?.emit('join-note', { - note_id: id, - auth: { - token: localStorage.token - } - }); - $socket?.on('note-events', noteEventHandler); if ($settings?.models) { selectedModelId = $settings?.models[0]; @@ -956,70 +959,72 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, {/if}
- {#if editor} -
-
- + {#if note?.write_access} + {#if editor} +
+
+ - + +
-
+ {/if} + + + + + + + + {/if} - - - - - - - - { downloadHandler(type); @@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, }} >
- + {#if note?.write_access} + + {:else} +
+ {$i18n.t('Read-Only Access')} +
+ {/if} {#if editor}
@@ -1158,7 +1165,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings, image={true} {files} placeholder={$i18n.t('Write something...')} - editable={versionIdx === null && !editing} + editable={versionIdx === null && !editing && note?.write_access} onSelectionUpdate={({ editor }) => { const { from, to } = editor.state.selection; const selectedText = editor.state.doc.textBetween(from, to, ' '); diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte index 04f7e4a261..c0d461205f 100644 --- a/src/lib/components/notes/Notes.svelte +++ b/src/lib/components/notes/Notes.svelte @@ -163,17 +163,33 @@ await getItemsPage(); }; - $: if (loaded && query !== undefined && sortKey !== undefined && viewOption !== undefined) { + $: if ( + loaded && + query !== undefined && + sortKey !== undefined && + permission !== undefined && + viewOption !== undefined + ) { init(); } const getItemsPage = async () => { itemsLoading = true; - const res = await searchNotes(localStorage.token, query, viewOption, sortKey, page).catch( - () => { - return []; - } - ); + + if (viewOption === 'created') { + permission = null; + } + + const res = await searchNotes( + localStorage.token, + query, + viewOption, + permission, + sortKey, + page + ).catch(() => { + return []; + }); if (res) { console.log(res); @@ -367,7 +383,7 @@ }} >
+ + {#if [null, 'shared'].includes(viewOption)} + + {/if}
@@ -411,17 +438,21 @@ {#if (items ?? []).length > 0} {@const notes = groupNotes(items)} -
+
- {#each Object.keys(notes) as timeRange} + {#each Object.keys(notes) as timeRange, idx}
{$i18n.t(timeRange)}
{#if displayOption === null} -
+
{#each notes[timeRange] as note, idx (note.id)}
{:else if displayOption === 'grid'}
{#each notes[timeRange] as note, idx (note.id)}