mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +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"):
|
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||||
group_ids = filter.get("group_ids", [])
|
group_ids = filter.get("group_ids", [])
|
||||||
user_id = filter.get("user_id")
|
user_id = filter.get("user_id")
|
||||||
|
|
||||||
dialect_name = db.bind.dialect.name
|
dialect_name = db.bind.dialect.name
|
||||||
|
|
||||||
# Public access
|
|
||||||
conditions = []
|
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:
|
if group_ids or user_id:
|
||||||
conditions.extend(
|
conditions.extend(
|
||||||
[
|
[
|
||||||
|
|
@ -109,7 +184,7 @@ class NoteTable:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
# User-level permission
|
# User-level permission (owner has all permissions)
|
||||||
if user_id:
|
if user_id:
|
||||||
conditions.append(Note.user_id == user_id)
|
conditions.append(Note.user_id == user_id)
|
||||||
|
|
||||||
|
|
@ -191,11 +266,16 @@ class NoteTable:
|
||||||
query = query.filter(Note.user_id != user_id)
|
query = query.filter(Note.user_id != user_id)
|
||||||
|
|
||||||
# Apply access control filtering
|
# Apply access control filtering
|
||||||
|
if "permission" in filter:
|
||||||
|
permission = filter["permission"]
|
||||||
|
else:
|
||||||
|
permission = "write"
|
||||||
|
|
||||||
query = self._has_permission(
|
query = self._has_permission(
|
||||||
db,
|
db,
|
||||||
query,
|
query,
|
||||||
filter,
|
filter,
|
||||||
permission="write",
|
permission=permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
order_by = filter.get("order_by")
|
order_by = filter.get("order_by")
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ async def search_notes(
|
||||||
request: Request,
|
request: Request,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
view_option: Optional[str] = None,
|
view_option: Optional[str] = None,
|
||||||
|
permission: Optional[str] = None,
|
||||||
order_by: Optional[str] = None,
|
order_by: Optional[str] = None,
|
||||||
direction: Optional[str] = None,
|
direction: Optional[str] = None,
|
||||||
page: Optional[int] = 1,
|
page: Optional[int] = 1,
|
||||||
|
|
@ -108,6 +109,8 @@ async def search_notes(
|
||||||
filter["query"] = query
|
filter["query"] = query
|
||||||
if view_option:
|
if view_option:
|
||||||
filter["view_option"] = view_option
|
filter["view_option"] = view_option
|
||||||
|
if permission:
|
||||||
|
filter["permission"] = permission
|
||||||
if order_by:
|
if order_by:
|
||||||
filter["order_by"] = order_by
|
filter["order_by"] = order_by
|
||||||
if direction:
|
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)):
|
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
|
||||||
if user.role != "admin" and not has_permission(
|
if user.role != "admin" and not has_permission(
|
||||||
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
|
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()
|
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 = '',
|
token: string = '',
|
||||||
query: string | null = null,
|
query: string | null = null,
|
||||||
viewOption: string | null = null,
|
viewOption: string | null = null,
|
||||||
|
permission: string | null = null,
|
||||||
sortKey: string | null = null,
|
sortKey: string | null = null,
|
||||||
page: number | null = null
|
page: number | null = null
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -109,6 +110,10 @@ export const searchNotes = async (
|
||||||
searchParams.append('view_option', viewOption);
|
searchParams.append('view_option', viewOption);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (permission !== null) {
|
||||||
|
searchParams.append('permission', permission);
|
||||||
|
}
|
||||||
|
|
||||||
if (sortKey !== null) {
|
if (sortKey !== null) {
|
||||||
searchParams.append('order_by', sortKey);
|
searchParams.append('order_by', sortKey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,16 @@
|
||||||
if (res) {
|
if (res) {
|
||||||
note = res;
|
note = res;
|
||||||
files = res.data.files || [];
|
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 {
|
} else {
|
||||||
goto('/');
|
goto('/');
|
||||||
return;
|
return;
|
||||||
|
|
@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await tick();
|
await tick();
|
||||||
$socket?.emit('join-note', {
|
|
||||||
note_id: id,
|
|
||||||
auth: {
|
|
||||||
token: localStorage.token
|
|
||||||
}
|
|
||||||
});
|
|
||||||
$socket?.on('note-events', noteEventHandler);
|
|
||||||
|
|
||||||
if ($settings?.models) {
|
if ($settings?.models) {
|
||||||
selectedModelId = $settings?.models[0];
|
selectedModelId = $settings?.models[0];
|
||||||
|
|
@ -956,6 +959,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center gap-0.5 translate-x-1">
|
<div class="flex items-center gap-0.5 translate-x-1">
|
||||||
|
{#if note?.write_access}
|
||||||
{#if editor}
|
{#if editor}
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
|
||||||
|
|
@ -1019,6 +1023,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
<AdjustmentsHorizontalOutline />
|
<AdjustmentsHorizontalOutline />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<NoteMenu
|
<NoteMenu
|
||||||
onDownload={(type) => {
|
onDownload={(type) => {
|
||||||
|
|
@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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">
|
<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 -->
|
<!-- check for same date, yesterday, last week, and other -->
|
||||||
|
|
||||||
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
|
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
|
||||||
|
|
@ -1099,6 +1102,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if note?.write_access}
|
||||||
<button
|
<button
|
||||||
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
|
|
@ -1106,10 +1110,13 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
||||||
}}
|
}}
|
||||||
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
|
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>
|
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
{$i18n.t('Read-Only Access')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if editor}
|
{#if editor}
|
||||||
<div class="flex items-center gap-1 px-1 min-w-fit">
|
<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}
|
image={true}
|
||||||
{files}
|
{files}
|
||||||
placeholder={$i18n.t('Write something...')}
|
placeholder={$i18n.t('Write something...')}
|
||||||
editable={versionIdx === null && !editing}
|
editable={versionIdx === null && !editing && note?.write_access}
|
||||||
onSelectionUpdate={({ editor }) => {
|
onSelectionUpdate={({ editor }) => {
|
||||||
const { from, to } = editor.state.selection;
|
const { from, to } = editor.state.selection;
|
||||||
const selectedText = editor.state.doc.textBetween(from, to, ' ');
|
const selectedText = editor.state.doc.textBetween(from, to, ' ');
|
||||||
|
|
|
||||||
|
|
@ -163,17 +163,33 @@
|
||||||
await getItemsPage();
|
await getItemsPage();
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (loaded && query !== undefined && sortKey !== undefined && viewOption !== undefined) {
|
$: if (
|
||||||
|
loaded &&
|
||||||
|
query !== undefined &&
|
||||||
|
sortKey !== undefined &&
|
||||||
|
permission !== undefined &&
|
||||||
|
viewOption !== undefined
|
||||||
|
) {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
|
||||||
const getItemsPage = async () => {
|
const getItemsPage = async () => {
|
||||||
itemsLoading = true;
|
itemsLoading = true;
|
||||||
const res = await searchNotes(localStorage.token, query, viewOption, sortKey, page).catch(
|
|
||||||
() => {
|
if (viewOption === 'created') {
|
||||||
return [];
|
permission = null;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
const res = await searchNotes(
|
||||||
|
localStorage.token,
|
||||||
|
query,
|
||||||
|
viewOption,
|
||||||
|
permission,
|
||||||
|
sortKey,
|
||||||
|
page
|
||||||
|
).catch(() => {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
|
@ -367,7 +383,7 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<DropdownOptions
|
||||||
align="start"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -411,17 +438,21 @@
|
||||||
{#if (items ?? []).length > 0}
|
{#if (items ?? []).length > 0}
|
||||||
{@const notes = groupNotes(items)}
|
{@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="">
|
<div class="">
|
||||||
{#each Object.keys(notes) as timeRange}
|
{#each Object.keys(notes) as timeRange, idx}
|
||||||
<div
|
<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)}
|
{$i18n.t(timeRange)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if displayOption === null}
|
{#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)}
|
{#each notes[timeRange] as note, idx (note.id)}
|
||||||
<div
|
<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"
|
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>
|
</div>
|
||||||
{:else if displayOption === 'grid'}
|
{:else if displayOption === 'grid'}
|
||||||
<div
|
<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)}
|
{#each notes[timeRange] as note, idx (note.id)}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue