mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 12:25:20 +00:00
enh: read only notes
This commit is contained in:
parent
307b37d5e2
commit
4363df175d
5 changed files with 236 additions and 100 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
<div class="flex items-center gap-0.5 translate-x-1">
|
||||
{#if editor}
|
||||
<div>
|
||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().undo().run();
|
||||
// versionNavigateHandler('prev');
|
||||
}}
|
||||
disabled={!editor.can().undo()}
|
||||
>
|
||||
<ArrowUturnLeft className="size-4" />
|
||||
</button>
|
||||
{#if note?.write_access}
|
||||
{#if editor}
|
||||
<div>
|
||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().undo().run();
|
||||
// versionNavigateHandler('prev');
|
||||
}}
|
||||
disabled={!editor.can().undo()}
|
||||
>
|
||||
<ArrowUturnLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().redo().run();
|
||||
// versionNavigateHandler('next');
|
||||
}}
|
||||
disabled={!editor.can().redo()}
|
||||
>
|
||||
<ArrowUturnRight className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
|
||||
on:click={() => {
|
||||
editor.chain().focus().redo().run();
|
||||
// versionNavigateHandler('next');
|
||||
}}
|
||||
disabled={!editor.can().redo()}
|
||||
>
|
||||
<ArrowUturnRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'chat') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'chat';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatBubbleOval />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'settings') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'settings';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AdjustmentsHorizontalOutline />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'chat') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'chat';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatBubbleOval />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
if (showPanel && selectedPanel === 'settings') {
|
||||
showPanel = false;
|
||||
} else {
|
||||
if (!showPanel) {
|
||||
showPanel = true;
|
||||
}
|
||||
selectedPanel = 'settings';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AdjustmentsHorizontalOutline />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<NoteMenu
|
||||
onDownload={(type) => {
|
||||
downloadHandler(type);
|
||||
|
|
@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-1 items-center text-xs font-medium text-gray-500 dark:text-gray-500 w-fit"
|
||||
class="flex gap-0.5 items-center text-xs font-medium text-gray-500 dark:text-gray-500 w-fit"
|
||||
>
|
||||
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit">
|
||||
<Calendar className="size-3.5" strokeWidth="2" />
|
||||
|
||||
<!-- check for same date, yesterday, last week, and other -->
|
||||
|
||||
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
|
||||
|
|
@ -1099,17 +1102,21 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
||||
>
|
||||
<Users className="size-3.5" strokeWidth="2" />
|
||||
|
||||
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||
</button>
|
||||
{#if note?.write_access}
|
||||
<button
|
||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
||||
>
|
||||
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||
</button>
|
||||
{:else}
|
||||
<div>
|
||||
{$i18n.t('Read-Only Access')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editor}
|
||||
<div class="flex items-center gap-1 px-1 min-w-fit">
|
||||
|
|
@ -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, ' ');
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-1.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
>
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
|
|
@ -386,6 +402,17 @@
|
|||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if [null, 'shared'].includes(viewOption)}
|
||||
<DropdownOptions
|
||||
align="start"
|
||||
bind:value={permission}
|
||||
items={[
|
||||
{ value: null, label: $i18n.t('Write') },
|
||||
{ value: 'read_only', label: $i18n.t('Read Only') }
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -411,17 +438,21 @@
|
|||
{#if (items ?? []).length > 0}
|
||||
{@const notes = groupNotes(items)}
|
||||
|
||||
<div class="@container h-full py-2 px-2.5">
|
||||
<div class="@container h-full py-2.5 px-2.5">
|
||||
<div class="">
|
||||
{#each Object.keys(notes) as timeRange}
|
||||
{#each Object.keys(notes) as timeRange, idx}
|
||||
<div
|
||||
class="mb-3 w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
|
||||
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
|
||||
>
|
||||
{$i18n.t(timeRange)}
|
||||
</div>
|
||||
|
||||
{#if displayOption === null}
|
||||
<div class="gap-1.5 flex flex-col">
|
||||
<div
|
||||
class="{Object.keys(notes).length - 1 !== idx
|
||||
? 'mb-3'
|
||||
: ''} gap-1.5 flex flex-col"
|
||||
>
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
class=" flex cursor-pointer w-full px-3.5 py-1.5 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
|
||||
|
|
@ -494,7 +525,9 @@
|
|||
</div>
|
||||
{:else if displayOption === 'grid'}
|
||||
<div
|
||||
class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
class="{Object.keys(notes).length - 1 !== idx
|
||||
? 'mb-5'
|
||||
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
{#each notes[timeRange] as note, idx (note.id)}
|
||||
<div
|
||||
|
|
|
|||
Loading…
Reference in a new issue