enh: read only notes

This commit is contained in:
Timothy Jaeryang Baek 2025-12-09 17:57:15 -05:00
parent 307b37d5e2
commit 4363df175d
5 changed files with 236 additions and 100 deletions

View file

@ -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")

View file

@ -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)
############################ ############################

View file

@ -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);
} }

View file

@ -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, ' ');

View file

@ -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