sharing: share a note from the web, at view or edit, with anyone on the instance
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 14s
CI & Build / integration (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m6s
Desktop (Tauri) / Update manifest (push) Canceled after 0s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Canceled after 2m25s
Desktop (Tauri) / Tauri desktop (Linux) (push) Canceled after 2m33s

The ACL has gated every read since M0, but nothing could write a share.

Server:
- shares_api: GET /api/users/directory (everyone but you, signed-in only),
  GET/POST /api/notes/<id>/shares and DELETE …/shares/<share_id>, owner
  only. Sharing again with the same person changes the permission
  (ON CONFLICT on the new unique index).
- acl.visible_to_user takes permission=; granted_to and shared_ids feed
  the serializer.
- Edit covers body and checklist (_get_editable). Everything else stays
  _get_owned. A view share's write is a 404 like a stranger's (#1984). An
  editor's PATCH naming anything but body is a 403.
- Serialized notes carry permission, shared and shared_by. A recipient
  never gets the owner's labels, and a #tag an editor types files under
  the owner's (it always went to note.owner_id).
- ?shared=with_me, also allowed in saved views. Trash and reminders are the
  owner's. purge_note drops the note's shares.
- Migration 0034: one share per note and person (and per group), permission
  limited to view and edit, an index for "shared with me".

Web:
- ShareDialog (one, mounted by the shell): pick a member, Can view or Can
  edit, change or remove existing shares, with loading, error and empty
  states.
- Card: "Shared by X" or "Shared" chip; owner-only actions and reminder
  buttons hidden for recipients; checkboxes inert at view.
- Editor: read-only at view; text and checklist only at edit; Share button
  for the owner.
- FilterBar: Shared with me. Repo seam gains `shares`; the offline desktop
  shows none of it (#5175 brings sharing there).

Tests: owner, recipient and stranger across reads, every write at view and
edit, tag filing, unshare, trash, delete and validation; web unit tests for
the facet and permission helpers. #5174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:01:53 -04:00
co-authored by Claude Opus 5.5
parent f100e5ef85
commit f53d377766
24 changed files with 911 additions and 50 deletions
@@ -0,0 +1,50 @@
"""shares: one grant per note and person, and only the permissions the app knows
Revision ID: 0034
Revises: 0033
Create Date: 2026-10-07
Nothing wrote a share until #5174, so the table never needed these. Now the Share
dialog does:
- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is
the target, so sharing a note with someone twice updates the one grant rather than
stacking two (the same for a group, ahead of step 15).
- A check that `permission` is `view` or `edit`.
- An index on `shared_with_user_id` for "Shared with me".
## Downgrade
Drops the indexes and the check. The rows stay.
"""
from alembic import op
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"uq_shares_resource_user",
"shares",
["resource_type", "resource_id", "shared_with_user_id"],
unique=True,
postgresql_where="shared_with_user_id IS NOT NULL",
)
op.create_index(
"uq_shares_resource_group",
"shares",
["resource_type", "resource_id", "shared_with_group_id"],
unique=True,
postgresql_where="shared_with_group_id IS NOT NULL",
)
op.create_index("ix_shares_user", "shares", ["shared_with_user_id"])
op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')")
def downgrade() -> None:
op.drop_constraint("ck_shares_permission", "shares", type_="check")
op.drop_index("ix_shares_user", table_name="shares")
op.drop_index("uq_shares_resource_group", table_name="shares")
op.drop_index("uq_shares_resource_user", table_name="shares")
+10 -1
View File
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
import type { TitleEntry } from "../stores/titles"; import type { TitleEntry } from "../stores/titles";
import type { User } from "../stores/session"; import type { User } from "../stores/session";
import type { PublicConfig } from "../stores/config"; import type { PublicConfig } from "../stores/config";
import type { DeviceToken, ImportResult, Repo } from "./repo"; import type { DeviceToken, ImportResult, Member, NoteShare, Repo } from "./repo";
const NEEDS_SERVER = "That's not available offline — connect a server to use it."; const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
@@ -91,4 +91,13 @@ export const local: Repo = {
remove: (id) => invoke<void>("saved_filters_remove", { id }), remove: (id) => invoke<void>("saved_filters_remove", { id }),
rename: (id, name) => invoke<SavedFilter>("saved_filters_rename", { id, name }), rename: (id, name) => invoke<SavedFilter>("saved_filters_rename", { id, name }),
}, },
// Sharing is between accounts on a server; the desktop gets it with sync (#5175).
// The Share controls are not shown here, so these only answer a stray call.
shares: {
directory: () => Promise.resolve<Member[]>([]),
list: () => Promise.resolve<NoteShare[]>([]),
share: () => Promise.reject<NoteShare[]>(new Error(NEEDS_SERVER)),
unshare: () => Promise.reject<NoteShare[]>(new Error(NEEDS_SERVER)),
},
}; };
+28
View File
@@ -46,6 +46,24 @@ export interface ChecklistItemChanges {
} }
// ---- sharing (#5174) ---------------------------------------------------------
/** A person on this instance, as the member directory shows them. */
export interface Member {
id: string;
display_name: string;
email: string;
}
export type SharePermission = "view" | "edit";
export interface NoteShare {
id: string;
member: Member;
permission: SharePermission;
created_at: string | null;
}
export interface ImportResult { export interface ImportResult {
source: string; source: string;
imported: number; imported: number;
@@ -116,6 +134,15 @@ export interface NotesRepo {
titles(): Promise<TitleEntry[]>; titles(): Promise<TitleEntry[]>;
} }
export interface SharesRepo {
/** Everyone on the instance but you. */
directory(): Promise<Member[]>;
list(noteId: string): Promise<NoteShare[]>;
/** Share with one member, or change their permission. Answers the note's shares. */
share(noteId: string, userId: string, permission: SharePermission): Promise<NoteShare[]>;
unshare(noteId: string, shareId: string): Promise<NoteShare[]>;
}
export interface SavedFiltersRepo { export interface SavedFiltersRepo {
list(): Promise<SavedFilter[]>; list(): Promise<SavedFilter[]>;
create(name: string, params: NoteFacets): Promise<SavedFilter>; create(name: string, params: NoteFacets): Promise<SavedFilter>;
@@ -130,4 +157,5 @@ export interface Repo {
labels: LabelsRepo; labels: LabelsRepo;
notes: NotesRepo; notes: NotesRepo;
savedFilters: SavedFiltersRepo; savedFilters: SavedFiltersRepo;
shares: SharesRepo;
} }
+12
View File
@@ -15,7 +15,9 @@ import type { PublicConfig } from "../stores/config";
import type { import type {
DeviceToken, DeviceToken,
ImportResult, ImportResult,
Member,
NoteChanges, NoteChanges,
NoteShare,
NoteCreateInput, NoteCreateInput,
NoteListQuery, NoteListQuery,
ChecklistItemChanges, ChecklistItemChanges,
@@ -35,6 +37,7 @@ function notesQuery(q: NoteListQuery): string {
if (q.facets?.has_attachment) params.set("has_attachment", "true"); if (q.facets?.has_attachment) params.set("has_attachment", "true");
if (q.facets?.created_after) params.set("created_after", q.facets.created_after); if (q.facets?.created_after) params.set("created_after", q.facets.created_after);
if (q.facets?.created_before) params.set("created_before", q.facets.created_before); if (q.facets?.created_before) params.set("created_before", q.facets.created_before);
if (q.facets?.shared) params.set("shared", q.facets.shared);
if (q.sort) params.set("sort", q.sort); if (q.sort) params.set("sort", q.sort);
return params.toString(); return params.toString();
} }
@@ -115,4 +118,13 @@ export const rest: Repo = {
remove: (id) => api.del<void>(`/api/saved-filters/${id}`), remove: (id) => api.del<void>(`/api/saved-filters/${id}`),
rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }), rename: (id, name) => api.patch<SavedFilter>(`/api/saved-filters/${id}`, { name }),
}, },
shares: {
directory: async () => (await api.get<{ members: Member[] }>("/api/users/directory")).members,
list: async (noteId) => (await api.get<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`)).shares,
share: async (noteId, userId, permission) =>
(await api.post<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares`, { user_id: userId, permission })).shares,
unshare: async (noteId, shareId) =>
(await api.del<{ shares: NoteShare[] }>(`/api/notes/${noteId}/shares/${shareId}`)).shares,
},
}; };
+2
View File
@@ -13,6 +13,7 @@ import Icon from "./Icon.vue";
import ExportNotes from "./ExportNotes.vue"; import ExportNotes from "./ExportNotes.vue";
import ImportNotes from "./ImportNotes.vue"; import ImportNotes from "./ImportNotes.vue";
import LabelsModal from "./LabelsModal.vue"; import LabelsModal from "./LabelsModal.vue";
import ShareDialog from "./ShareDialog.vue";
import { isDesktop } from "../desktop/bridge"; import { isDesktop } from "../desktop/bridge";
import { useLensName } from "../composables/useLensName"; import { useLensName } from "../composables/useLensName";
import { facetsToQuery } from "../notes/facets"; import { facetsToQuery } from "../notes/facets";
@@ -552,6 +553,7 @@ async function signOut() {
<LabelsModal v-if="managing" @close="managing = false" /> <LabelsModal v-if="managing" @close="managing = false" />
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" /> <CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
<ShareDialog v-if="ui.shareNoteId" :note-id="ui.shareNoteId" @close="ui.closeShare()" />
<BaseModal <BaseModal
v-if="showShortcuts" v-if="showShortcuts"
+15
View File
@@ -9,6 +9,7 @@ import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime"; import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
import Icon from "./Icon.vue"; import Icon from "./Icon.vue";
import { errorMessage } from "../api/errors"; import { errorMessage } from "../api/errors";
import { isDesktop } from "../desktop/bridge";
// A dead-simple facet bar over the board: color + labels + has-reminder // A dead-simple facet bar over the board: color + labels + has-reminder
// + has-attachment + created-date range. The URL query IS the state, so a // + has-attachment + created-date range. The URL query IS the state, so a
@@ -43,6 +44,12 @@ function toggleReminder() {
function toggleAttachment() { function toggleAttachment() {
patch({ has_attachment: facets.value.has_attachment ? undefined : true }); patch({ has_attachment: facets.value.has_attachment ? undefined : true });
} }
// Sharing is between accounts on a server, so the offline desktop has no shared notes
// to narrow to (#5174).
const sharingAvailable = !isDesktop();
function toggleShared() {
patch({ shared: facets.value.shared ? undefined : "with_me" });
}
function onFrom(e: Event) { function onFrom(e: Event) {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
@@ -129,6 +136,14 @@ const chipOff = "border-neutral-300 text-neutral-600 hover:bg-neutral-100 dark:b
<button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment"> <button type="button" :class="[chipBase, facets.has_attachment ? chipOn : chipOff]" @click="toggleAttachment">
Has attachment Has attachment
</button> </button>
<button
v-if="sharingAvailable"
type="button"
:class="[chipBase, facets.shared ? chipOn : chipOff]"
@click="toggleShared"
>
Shared with me
</button>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
+1
View File
@@ -29,6 +29,7 @@ const paths: Record<string, string> = {
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>', paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>', link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>', filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>', copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
}; };
</script> </script>
+50 -4
View File
@@ -16,6 +16,9 @@ import {
} from "../composables/useCardDrag"; } from "../composables/useCardDrag";
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime"; import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
import { useConfigStore } from "../stores/config"; import { useConfigStore } from "../stores/config";
import { useUiStore } from "../stores/ui";
import { canEditText, isOwnNote } from "../notes/sharing";
import { isDesktop } from "../desktop/bridge";
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>(); const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -32,6 +35,21 @@ const emit = defineEmits<{
}>(); }>();
const notes = useNotesStore(); const notes = useNotesStore();
const config = useConfigStore(); const config = useConfigStore();
const ui = useUiStore();
// --- Sharing (#5174). Someone else's note shows who shared it and offers none of
// the owner's controls; at `view` its checkboxes are inert too. Sharing needs a
// server, so the offline desktop has no Share button (its notes are all its own). ---
const own = computed(() => isOwnNote(props.note));
const editable = computed(() => canEditText(props.note));
const canShare = computed(() => own.value && !isDesktop());
const sharedLine = computed(() => {
if (!own.value) {
const who = props.note.shared_by?.display_name || "someone";
return props.note.permission === "view" ? `Shared by ${who} · view only` : `Shared by ${who}`;
}
return props.note.shared ? "Shared" : "";
});
// --- Retention countdown. A note in Trash is on a clock, and the card is the only // --- Retention countdown. A note in Trash is on a clock, and the card is the only
// place someone browsing Trash would ever find that out in time to restore it. // place someone browsing Trash would ever find that out in time to restore it.
@@ -105,7 +123,8 @@ const root = ref<HTMLElement | null>(null);
// Pointer rather than native HTML5 drag-and-drop because that API never fires from // Pointer rather than native HTML5 drag-and-drop because that API never fires from
// touch — on a phone this did nothing at all. One code path now covers mouse, touch // touch — on a phone this did nothing at all. One code path now covers mouse, touch
// and stylus. See composables/useCardDrag.ts for why the state is shared. --- // and stylus. See composables/useCardDrag.ts for why the state is shared. ---
const canDrag = () => !!props.reorderable && !props.note.trashed; // Board order is the owner's; a shared note's place is the recipient's from #5176.
const canDrag = () => !!props.reorderable && !props.note.trashed && own.value;
const dragging = computed(() => draggingId.value === props.note.id); const dragging = computed(() => draggingId.value === props.note.id);
const dragOver = computed(() => overId.value === props.note.id); const dragOver = computed(() => overId.value === props.note.id);
@@ -308,7 +327,12 @@ const tagColors = computed<Record<string, string>>(() => {
blank and the link is never unreachable. --> blank and the link is never unreachable. -->
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" /> <LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300"> <div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" /> <MarkdownText
:text="bodyPreview"
:tag-colors="tagColors"
:toggleable="editable"
@toggle="toggleTask"
/>
</div> </div>
<p <p
v-if="!note.body && !note.items.length && !note.attachments.length" v-if="!note.body && !note.items.length && !note.attachments.length"
@@ -329,7 +353,17 @@ const tagColors = computed<Record<string, string>>(() => {
two paragraphs instead of always after them. Rendering both would have shown two paragraphs instead of always after them. Rendering both would have shown
every list twice. --> every list twice. -->
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5"> <div v-if="sharedLine" class="mt-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 dark:bg-white/10 dark:text-neutral-300"
>
<Icon name="users" class="!h-3 !w-3" />
{{ sharedLine }}
</span>
</div>
<!-- A reminder is its owner's: someone the note is shared with isn't alerted by it. -->
<div v-if="note.remind_at && own" class="mt-2 flex flex-wrap items-center gap-1.5">
<span <span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class=" :class="
@@ -424,7 +458,9 @@ const tagColors = computed<Record<string, string>>(() => {
<Icon name="grip" /> <Icon name="grip" />
</button> </button>
<!-- Every action here is the owner's, so a shared note has no action set. -->
<div <div
v-if="own"
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10" class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
> >
<template v-if="note.trashed"> <template v-if="note.trashed">
@@ -441,7 +477,17 @@ const tagColors = computed<Record<string, string>>(() => {
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</template> </template>
<template v-else> <template v-else-if="own">
<button
v-if="canShare"
type="button"
class="icon-btn"
title="Share"
aria-label="Share"
@click="ui.openShare(note.id)"
>
<Icon name="users" />
</button>
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
+51 -9
View File
@@ -11,6 +11,9 @@ import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion"; import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses } from "../notes/colors"; import { labelChipClasses } from "../notes/colors";
import { canEditText, isOwnNote } from "../notes/sharing";
import { useUiStore } from "../stores/ui";
import { isDesktop } from "../desktop/bridge";
import { import {
afterEnter, afterEnter,
type EditorBlock, type EditorBlock,
@@ -114,6 +117,19 @@ const liveNote = computed<Note>(() =>
); );
const bodyPlaceholder = "Take a note…"; const bodyPlaceholder = "Take a note…";
// Sharing (#5174). Someone else's note: at `edit` its text and checklist are theirs to
// change and nothing else is; at `view` it is read-only. The server enforces both —
// this only stops the editor offering what would be refused.
const ui = useUiStore();
const own = computed(() => isOwnNote(liveNote.value));
const editable = computed(() => canEditText(liveNote.value));
const canShare = computed(() => own.value && !isCreate.value && !isDesktop());
const sharedLine = computed(() => {
if (own.value) return "";
const who = liveNote.value.shared_by?.display_name || "someone";
return editable.value ? `Shared by ${who} · you can edit the text` : `Shared by ${who} · view only`;
});
// Keep local state in sync when the edited note changes (modal reused for another note). // Keep local state in sync when the edited note changes (modal reused for another note).
watch( watch(
() => props.note, () => props.note,
@@ -353,6 +369,7 @@ function onProseInput(index: number, e: Event): void {
* however many lines became checkboxes and would otherwise keep its old height. * however many lines became checkboxes and would otherwise keep its old height.
*/ */
function onProseBlur(index: number): void { function onProseBlur(index: number): void {
if (!editable.value) return;
const promoted = promoteTasks(blocks.value, index); const promoted = promoteTasks(blocks.value, index);
if (promoted === blocks.value) return; if (promoted === blocks.value) return;
blocks.value = promoted; blocks.value = promoted;
@@ -369,6 +386,7 @@ function onProseKeydown(e: KeyboardEvent): void {
/** Enter on an item makes the next one; on an EMPTY item it ends the list. */ /** Enter on an item makes the next one; on an EMPTY item it ends the list. */
function onTaskEnter(index: number): void { function onTaskEnter(index: number): void {
if (!editable.value) return;
const next = afterEnter(blocks.value, index); const next = afterEnter(blocks.value, index);
blocks.value = next.blocks; blocks.value = next.blocks;
void focusBlock(next.focus); void focusBlock(next.focus);
@@ -383,6 +401,7 @@ function onTaskEnter(index: number): void {
* only one of them can offer. * only one of them can offer.
*/ */
function onTaskBackspace(index: number, e: KeyboardEvent): void { function onTaskBackspace(index: number, e: KeyboardEvent): void {
if (!editable.value) return;
const el = e.target as HTMLInputElement; const el = e.target as HTMLInputElement;
if (el.value !== "" || el.selectionStart !== 0) return; if (el.value !== "" || el.selectionStart !== 0) return;
e.preventDefault(); e.preventDefault();
@@ -487,6 +506,7 @@ async function onFileChange(e: Event) {
input.value = ""; input.value = "";
} }
async function onPaste(e: ClipboardEvent) { async function onPaste(e: ClipboardEvent) {
if (!own.value) return;
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/")); const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
const file = item?.getAsFile(); const file = item?.getAsFile();
if (file) { if (file) {
@@ -563,12 +583,17 @@ function revPreview(rev: NoteRevision): string {
@paste="onPaste" @paste="onPaste"
> >
<div class="flex flex-col gap-2 p-4"> <div class="flex flex-col gap-2 p-4">
<p v-if="sharedLine" class="flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400">
<Icon name="users" class="!h-3.5 !w-3.5" />
{{ sharedLine }}
</p>
<div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2"> <div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2">
<template v-for="att in liveNote.attachments" :key="att.id"> <template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image → inline thumbnail --> <!-- Image → inline thumbnail -->
<div v-if="attKind(att.mime) === 'image'" class="group/att relative"> <div v-if="attKind(att.mime) === 'image'" class="group/att relative">
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" /> <img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
<button <button
v-if="own"
type="button" type="button"
class="hover-reveal absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100" class="hover-reveal absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
aria-label="Remove attachment" aria-label="Remove attachment"
@@ -584,6 +609,7 @@ function revPreview(rev: NoteRevision): string {
> >
<audio controls :src="att.url" class="h-8 max-w-[220px]"></audio> <audio controls :src="att.url" class="h-8 max-w-[220px]"></audio>
<button <button
v-if="own"
type="button" type="button"
class="text-neutral-400 hover:text-red-500" class="text-neutral-400 hover:text-red-500"
aria-label="Remove attachment" aria-label="Remove attachment"
@@ -603,6 +629,7 @@ function revPreview(rev: NoteRevision): string {
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span> <span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span> <span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
<button <button
v-if="own"
type="button" type="button"
class="ml-1 text-neutral-400 hover:text-red-500" class="ml-1 text-neutral-400 hover:text-red-500"
aria-label="Remove attachment" aria-label="Remove attachment"
@@ -631,7 +658,7 @@ function revPreview(rev: NoteRevision): string {
v-for="p in liveNote.previews" v-for="p in liveNote.previews"
:key="p.id" :key="p.id"
:preview="p" :preview="p"
:removable="!liveNote.trashed" :removable="!liveNote.trashed && own"
@remove="notes.deletePreview(liveNote.id, p.id)" @remove="notes.deletePreview(liveNote.id, p.id)"
/> />
</div> </div>
@@ -646,6 +673,7 @@ function revPreview(rev: NoteRevision): string {
type="checkbox" type="checkbox"
class="h-4 w-4 shrink-0 accent-brand" class="h-4 w-4 shrink-0 accent-brand"
:checked="block.checked" :checked="block.checked"
:disabled="!editable"
:aria-label="block.text || 'Checklist item'" :aria-label="block.text || 'Checklist item'"
@change="setChecked(i, ($event.target as HTMLInputElement).checked)" @change="setChecked(i, ($event.target as HTMLInputElement).checked)"
/> />
@@ -653,6 +681,7 @@ function revPreview(rev: NoteRevision): string {
:ref="(el) => setBlockEl(block.id, el)" :ref="(el) => setBlockEl(block.id, el)"
:value="block.text" :value="block.text"
type="text" type="text"
:readonly="!editable"
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none" class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
:class="block.checked ? 'text-neutral-400 line-through' : ''" :class="block.checked ? 'text-neutral-400 line-through' : ''"
@input="setText(i, ($event.target as HTMLInputElement).value)" @input="setText(i, ($event.target as HTMLInputElement).value)"
@@ -660,6 +689,7 @@ function revPreview(rev: NoteRevision): string {
@keydown.backspace="onTaskBackspace(i, $event)" @keydown.backspace="onTaskBackspace(i, $event)"
/> />
<button <button
v-if="editable"
type="button" type="button"
class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200" class="hover-reveal shrink-0 text-neutral-300 opacity-0 hover:text-neutral-600 focus:opacity-100 group-hover/item:opacity-100 dark:hover:text-neutral-200"
aria-label="Delete item" aria-label="Delete item"
@@ -673,7 +703,8 @@ function revPreview(rev: NoteRevision): string {
:ref="(el) => setBlockEl(block.id, el)" :ref="(el) => setBlockEl(block.id, el)"
:value="block.text" :value="block.text"
rows="1" rows="1"
:placeholder="i === 0 ? bodyPlaceholder : ''" :readonly="!editable"
:placeholder="i === 0 && editable ? bodyPlaceholder : ''"
class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400" class="w-full resize-none overflow-hidden bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
@input="onProseInput(i, $event)" @input="onProseInput(i, $event)"
@keydown="onProseKeydown" @keydown="onProseKeydown"
@@ -707,7 +738,7 @@ function revPreview(rev: NoteRevision): string {
</span> </span>
</div> </div>
<div v-if="richEnabled" class="flex items-center gap-2 pt-1"> <div v-if="richEnabled && own" class="flex items-center gap-2 pt-1">
<Icon name="bell" class="text-neutral-400" /> <Icon name="bell" class="text-neutral-400" />
<input <input
type="datetime-local" type="datetime-local"
@@ -725,7 +756,7 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
</div> </div>
<div v-if="richEnabled && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs"> <div v-if="richEnabled && own && liveNote.remind_at" class="flex flex-wrap items-center gap-2 pl-6 text-xs">
<label class="text-neutral-400">Repeat</label> <label class="text-neutral-400">Repeat</label>
<select <select
:value="liveNote.recurrence ?? ''" :value="liveNote.recurrence ?? ''"
@@ -785,7 +816,7 @@ function revPreview(rev: NoteRevision): string {
<div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800"> <div class="flex items-center justify-end gap-2 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<div class="flex items-center gap-0.5"> <div class="flex items-center gap-0.5">
<button <button
v-if="richEnabled && !liveNote.trashed" v-if="richEnabled && !liveNote.trashed && own"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Attach a file" title="Attach a file"
@@ -796,7 +827,7 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" /> <input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<button <button
v-if="!liveNote.trashed" v-if="!liveNote.trashed && editable"
type="button" type="button"
class="icon-btn" class="icon-btn"
title="Add a checklist" title="Add a checklist"
@@ -806,12 +837,23 @@ function revPreview(rev: NoteRevision): string {
<Icon name="checkbox" /> <Icon name="checkbox" />
</button> </button>
<LabelPicker <LabelPicker
v-if="richEnabled && !liveNote.trashed" v-if="richEnabled && !liveNote.trashed && own"
:model-value="labelList" :model-value="labelList"
@update:model-value="onLabelsChange" @update:model-value="onLabelsChange"
/> />
<button <button
v-if="!isCreate" v-if="canShare && !liveNote.trashed"
type="button"
class="icon-btn"
:class="liveNote.shared ? 'text-brand-700 dark:text-brand' : ''"
title="Share"
aria-label="Share"
@click="ui.openShare(liveNote.id)"
>
<Icon name="users" />
</button>
<button
v-if="!isCreate && own"
type="button" type="button"
class="icon-btn" class="icon-btn"
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''" :class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
@@ -822,7 +864,7 @@ function revPreview(rev: NoteRevision): string {
> >
<Icon name="history" /> <Icon name="history" />
</button> </button>
<template v-if="!isCreate && !liveNote.trashed"> <template v-if="!isCreate && !liveNote.trashed && own">
<button <button
type="button" type="button"
class="icon-btn" class="icon-btn"
+163
View File
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { repo } from "../adapters";
import type { Member, NoteShare, SharePermission } from "../adapters/repo";
import { errorMessage } from "../api/errors";
import { useNotesStore } from "../stores/notes";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
// Share a note with other people on this instance (#5174). The owner picks a member
// from the directory and says whether they may only read it or also change its text.
// Only the owner opens this; the server refuses everyone else regardless.
const props = defineProps<{ noteId: string }>();
const emit = defineEmits<{ (e: "close"): void }>();
const notes = useNotesStore();
const PERMISSIONS: { value: SharePermission; label: string }[] = [
{ value: "view", label: "Can view" },
{ value: "edit", label: "Can edit" },
];
const members = ref<Member[]>([]);
const shares = ref<NoteShare[]>([]);
const loading = ref(true);
const loadError = ref("");
const error = ref("");
const busy = ref(false);
const pick = ref("");
const permission = ref<SharePermission>("view");
/** The people it isn't shared with yet. */
const available = computed(() => {
const taken = new Set(shares.value.map((s) => s.member.id));
return members.value.filter((m) => !taken.has(m.id));
});
async function load() {
loading.value = true;
loadError.value = "";
try {
[members.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
} catch (e) {
loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
} finally {
loading.value = false;
}
}
async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
busy.value = true;
error.value = "";
try {
shares.value = await fn();
notes.setShared(props.noteId, shares.value.length > 0);
} catch (e) {
error.value = errorMessage(e, fallback);
} finally {
busy.value = false;
}
}
async function add() {
if (!pick.value) return;
const userId = pick.value;
await run(() => repo.shares.share(props.noteId, userId, permission.value), "Couldn't share the note.");
if (!error.value) pick.value = "";
}
function change(share: NoteShare, next: SharePermission) {
void run(() => repo.shares.share(props.noteId, share.member.id, next), "Couldn't change that.");
}
function remove(share: NoteShare) {
void run(() => repo.shares.unshare(props.noteId, share.id), "Couldn't stop sharing.");
}
const selectClass =
"rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60 dark:border-neutral-700 dark:bg-neutral-800";
onMounted(load);
</script>
<template>
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')">
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
<h2 class="text-sm font-semibold">Share note</h2>
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
</div>
<div class="flex flex-col gap-4 p-4">
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
<div v-else-if="loadError" class="py-4 text-center">
<p class="text-sm text-red-600 dark:text-red-400">{{ loadError }}</p>
<button
type="button"
class="mt-2 rounded-md border border-neutral-300 px-3 py-1 text-sm hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="load"
>
Retry
</button>
</div>
<template v-else>
<p v-if="!members.length" class="text-sm text-neutral-500 dark:text-neutral-400">
No one else has an account here yet. An admin can invite people from Settings.
</p>
<form v-else-if="available.length" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person" :disabled="busy">
<option value="" disabled>Choose someone…</option>
<option v-for="m in available" :key="m.id" :value="m.id">{{ m.display_name }} ({{ m.email }})</option>
</select>
<select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy">
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
<button
type="submit"
class="rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60"
:disabled="busy || !pick"
>
Share
</button>
</form>
<p v-if="error" role="alert" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Shared with</p>
<p v-if="!shares.length" class="text-sm text-neutral-400">Only you.</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ s.member.display_name }}</p>
<p class="truncate text-xs text-neutral-400">{{ s.member.email }}</p>
</div>
<select
:value="s.permission"
:class="selectClass"
:aria-label="`Permission for ${s.member.display_name}`"
:disabled="busy"
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
>
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
<button
type="button"
class="icon-btn"
:title="`Stop sharing with ${s.member.display_name}`"
:aria-label="`Stop sharing with ${s.member.display_name}`"
:disabled="busy"
@click="remove(s)"
>
<Icon name="close" />
</button>
</li>
</ul>
</div>
</template>
</div>
</BaseModal>
</template>
+3
View File
@@ -22,6 +22,7 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
if (after) f.created_after = after; if (after) f.created_after = after;
const before = one(q.created_before); const before = one(q.created_before);
if (before) f.created_before = before; if (before) f.created_before = before;
if (one(q.shared) === "with_me") f.shared = "with_me";
return f; return f;
} }
@@ -33,6 +34,7 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
if (f.has_attachment) q.has_attachment = "true"; if (f.has_attachment) q.has_attachment = "true";
if (f.created_after) q.created_after = f.created_after; if (f.created_after) q.created_after = f.created_after;
if (f.created_before) q.created_before = f.created_before; if (f.created_before) q.created_before = f.created_before;
if (f.shared) q.shared = f.shared;
return q; return q;
} }
@@ -44,5 +46,6 @@ export function facetCount(f: NoteFacets): number {
if (f.has_reminder) n++; if (f.has_reminder) n++;
if (f.has_attachment) n++; if (f.has_attachment) n++;
if (f.created_after || f.created_before) n++; if (f.created_after || f.created_before) n++;
if (f.shared) n++;
return n; return n;
} }
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { canEditText, isOwnNote } from "./sharing";
import { facetCount, facetsFromQuery, facetsToQuery } from "./facets";
import type { Note } from "../stores/notes";
const note = (extra: Partial<Note>): Note => ({ ...({} as Note), ...extra });
describe("how a note is held", () => {
it("is your own when the source says nothing of sharing", () => {
expect(isOwnNote(note({}))).toBe(true);
expect(canEditText(note({}))).toBe(true);
});
it("lets an edit share change the text but not own the note", () => {
expect(isOwnNote(note({ permission: "edit" }))).toBe(false);
expect(canEditText(note({ permission: "edit" }))).toBe(true);
});
it("leaves a view share read-only", () => {
expect(canEditText(note({ permission: "view" }))).toBe(false);
});
});
describe("the Shared with me facet", () => {
it("round-trips through the URL and counts as one filter", () => {
const facets = facetsFromQuery({ shared: "with_me" });
expect(facets).toEqual({ shared: "with_me" });
expect(facetsToQuery(facets)).toEqual({ shared: "with_me" });
expect(facetCount(facets)).toBe(1);
});
it("ignores a value it doesn't know", () => {
expect(facetsFromQuery({ shared: "by_me" })).toEqual({});
});
});
+14
View File
@@ -0,0 +1,14 @@
import type { Note } from "../stores/notes";
// How the signed-in person holds a note (#5174). The owner does everything; someone it
// is shared with at `edit` changes the body and checklist; at `view`, nothing. A note
// with no permission came from a source without sharing (the offline desktop): it is
// the person's own.
export function isOwnNote(note: Note): boolean {
return (note.permission ?? "owner") === "owner";
}
export function canEditText(note: Note): boolean {
return (note.permission ?? "owner") !== "view";
}
+21
View File
@@ -13,8 +13,14 @@ export interface NoteFacets {
has_attachment?: boolean; has_attachment?: boolean;
created_after?: string; created_after?: string;
created_before?: string; created_before?: string;
/** "with_me": only notes other people have shared with you (#5174). */
shared?: "with_me";
} }
/** How the signed-in person holds a note. A note from a source that knows nothing of
* sharing (the offline desktop) has none, and is theirs. */
export type NotePermission = "owner" | "edit" | "view";
export interface NoteLabel { export interface NoteLabel {
id: string; id: string;
name: string; name: string;
@@ -86,6 +92,13 @@ export interface Note {
previews: LinkPreview[]; previews: LinkPreview[];
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
// Sharing (#5174). `edit` may change the body and checklist; everything else is the
// owner's. A recipient's copy arrives without the owner's labels.
permission?: NotePermission;
/** The owner's own note: whether it is shared with anyone. */
shared?: boolean;
/** Someone else's note: who shared it. */
shared_by?: { id: string; display_name: string } | null;
} }
export const useNotesStore = defineStore("notes", () => { export const useNotesStore = defineStore("notes", () => {
@@ -108,6 +121,7 @@ export const useNotesStore = defineStore("notes", () => {
const inView = const inView =
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived; v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived;
if (!inView) return false; if (!inView) return false;
if (activeFacets.value.shared === "with_me" && (n.permission ?? "owner") === "owner") return false;
if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value); if (activeLabel.value) return n.labels.some((lb) => lb.id === activeLabel.value);
return true; return true;
} }
@@ -262,9 +276,16 @@ export const useNotesStore = defineStore("notes", () => {
return note; return note;
} }
/** The Share dialog changed who a note is shared with; the card's indicator follows. */
function setShared(id: string, shared: boolean): void {
const note = items.value.find((n) => n.id === id);
if (note) note.shared = shared;
}
return { return {
items, items,
loading, loading,
setShared,
view, view,
activeLabel, activeLabel,
activeFacets, activeFacets,
+13
View File
@@ -53,7 +53,20 @@ export const useUiStore = defineStore("ui", () => {
run?.(); run?.();
} }
// The note whose Share dialog is open (#5174), or null. One dialog for the app,
// mounted by the shell, so the card and the editor open the same one.
const shareNoteId = ref<string | null>(null);
function openShare(id: string) {
shareNoteId.value = id;
}
function closeShare() {
shareNoteId.value = null;
}
return { return {
shareNoteId,
openShare,
closeShare,
composeTick, composeTick,
composeSeed, composeSeed,
requestCompose, requestCompose,
+49 -1
View File
@@ -7,12 +7,21 @@ from sqlalchemy import ColumnElement, exists, or_, select
from .models.group import GroupMember from .models.group import GroupMember
from .models.share import Share from .models.share import Share
# What a share grants (#5174). `edit` is the note's body and checklist; everything
# else about a note stays its owner's. Ordered weakest first, so the strongest of a
# person's grants is the one with the highest index.
VIEW = "view"
EDIT = "edit"
PERMISSIONS = (VIEW, EDIT)
def visible_to_user( def visible_to_user(
resource_type: str, resource_type: str,
owner_column, owner_column,
resource_id_column, resource_id_column,
user_id: uuid.UUID, user_id: uuid.UUID,
*,
permission: str | None = None,
) -> ColumnElement[bool]: ) -> ColumnElement[bool]:
"""Boolean SQL predicate: is this resource visible to ``user_id``? """Boolean SQL predicate: is this resource visible to ``user_id``?
@@ -21,7 +30,10 @@ def visible_to_user(
mutation) of shareable user data through this — never assume a single operator mutation) of shareable user data through this — never assume a single operator
(family rule 47). (family rule 47).
Usage (M1+), e.g. for notes:: With ``permission``, only shares at that level count: ``permission=EDIT`` is
"may this user change it", which the owner always may.
Usage, e.g. for notes::
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, uid)) stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, uid))
@@ -30,17 +42,53 @@ def visible_to_user(
owner_column: the resource's owner column (e.g. ``Note.owner_id``). owner_column: the resource's owner column (e.g. ``Note.owner_id``).
resource_id_column: the resource's primary-key column (e.g. ``Note.id``). resource_id_column: the resource's primary-key column (e.g. ``Note.id``).
user_id: the viewer. user_id: the viewer.
permission: the level a share must grant to count, or None for any.
""" """
user_group_ids = select(GroupMember.group_id).where(GroupMember.user_id == user_id) user_group_ids = select(GroupMember.group_id).where(GroupMember.user_id == user_id)
granted = [Share.permission == permission] if permission is not None else []
direct_share = exists().where( direct_share = exists().where(
Share.resource_type == resource_type, Share.resource_type == resource_type,
Share.resource_id == resource_id_column, Share.resource_id == resource_id_column,
Share.shared_with_user_id == user_id, Share.shared_with_user_id == user_id,
*granted,
) )
group_share = exists().where( group_share = exists().where(
Share.resource_type == resource_type, Share.resource_type == resource_type,
Share.resource_id == resource_id_column, Share.resource_id == resource_id_column,
Share.shared_with_group_id.in_(user_group_ids), Share.shared_with_group_id.in_(user_group_ids),
*granted,
) )
return or_(owner_column == user_id, direct_share, group_share) return or_(owner_column == user_id, direct_share, group_share)
async def granted_to(db, resource_type: str, resource_ids: list, user_id: uuid.UUID) -> dict:
"""resource_id -> the strongest permission shared with ``user_id``, directly or
through a group. Resources with no share to them are absent."""
if not resource_ids:
return {}
user_group_ids = select(GroupMember.group_id).where(GroupMember.user_id == user_id)
rows = await db.execute(
select(Share.resource_id, Share.permission).where(
Share.resource_type == resource_type,
Share.resource_id.in_(resource_ids),
or_(Share.shared_with_user_id == user_id, Share.shared_with_group_id.in_(user_group_ids)),
)
)
best: dict = {}
for rid, perm in rows.all():
if PERMISSIONS.index(perm) >= PERMISSIONS.index(best.get(rid, VIEW)):
best[rid] = perm
return best
async def shared_ids(db, resource_type: str, resource_ids: list) -> set:
"""Which of these resources have been shared with anyone."""
if not resource_ids:
return set()
rows = await db.scalars(
select(Share.resource_id)
.where(Share.resource_type == resource_type, Share.resource_id.in_(resource_ids))
.distinct()
)
return set(rows.all())
+2
View File
@@ -24,6 +24,7 @@ from .retention import run_sweeper
from .saved_filters import bp as saved_filters_bp from .saved_filters import bp as saved_filters_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live from .settings import get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings_api import bp as settings_bp from .settings_api import bp as settings_bp
from .shares_api import bp as shares_bp
from .sync import bp as sync_bp, protocol_advertisement from .sync import bp as sync_bp, protocol_advertisement
# Without this, `logger.info` from this package goes nowhere: hypercorn configures its # Without this, `logger.info` from this package goes nowhere: hypercorn configures its
@@ -95,6 +96,7 @@ def create_app() -> Quart:
app.register_blueprint(settings_bp) app.register_blueprint(settings_bp)
app.register_blueprint(invites_bp) app.register_blueprint(invites_bp)
app.register_blueprint(accounts_bp) app.register_blueprint(accounts_bp)
app.register_blueprint(shares_bp)
app.register_blueprint(sync_bp) app.register_blueprint(sync_bp)
app.register_blueprint(saved_filters_bp) app.register_blueprint(saved_filters_bp)
app.register_blueprint(client_bp) app.register_blueprint(client_bp)
+36 -20
View File
@@ -49,6 +49,7 @@ from .helpers import (
ALLOWED_IMAGE_MIMES, ALLOWED_IMAGE_MIMES,
VALID_FILTERS, VALID_FILTERS,
_attachment_ext, _attachment_ext,
_get_editable,
_get_owned, _get_owned,
_header_filename, _header_filename,
_safe_filename, _safe_filename,
@@ -119,9 +120,18 @@ async def list_notes():
after_param = request.args.get("created_after") after_param = request.args.get("created_after")
before_param = request.args.get("created_before") before_param = request.args.get("created_before")
sort = request.args.get("sort") sort = request.args.get("sort")
shared = request.args.get("shared")
if shared not in (None, "", "with_me"):
return json_error("invalid shared", 400)
async with session_scope() as db: async with session_scope() as db:
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id)) stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
stmt = apply_filter(stmt, filter_name) stmt = apply_filter(stmt, filter_name)
# Trash is the owner's: a note its owner trashed leaves a recipient's board
# rather than turning up in their Trash, where they could do nothing with it.
if filter_name == "trash":
stmt = stmt.where(Note.owner_id == g.user_id)
if shared == "with_me":
stmt = stmt.where(Note.owner_id != g.user_id)
for raw_label in label_params: for raw_label in label_params:
lid = parse_uuid(raw_label) lid = parse_uuid(raw_label)
if lid is None: if lid is None:
@@ -156,7 +166,7 @@ async def list_notes():
else: else:
stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc()) stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
notes = (await db.scalars(stmt)).all() notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)}) return jsonify({"notes": await _serialize_notes(db, notes, g.user_id)})
@bp.get("/reminders") @bp.get("/reminders")
@@ -166,14 +176,16 @@ async def list_reminders():
stmt = ( stmt = (
select(Note) select(Note)
.where( .where(
visible_to_user("note", Note.owner_id, Note.id, g.user_id), # A reminder is its owner's, like everything but a note's text: someone
# a note is shared with isn't alerted by it and can't clear it.
Note.owner_id == g.user_id,
Note.deleted_at.is_(None), Note.deleted_at.is_(None),
Note.remind_at.is_not(None), Note.remind_at.is_not(None),
) )
.order_by(Note.remind_at.asc()) .order_by(Note.remind_at.asc())
) )
notes = (await db.scalars(stmt)).all() notes = (await db.scalars(stmt)).all()
return jsonify({"notes": await _serialize_notes(db, notes)}) return jsonify({"notes": await _serialize_notes(db, notes, g.user_id)})
@bp.post("/<note_id>/reminder/complete") @bp.post("/<note_id>/reminder/complete")
@@ -191,7 +203,7 @@ async def complete_reminder(note_id: str):
note.remind_at = None note.remind_at = None
note.recurrence = None note.recurrence = None
await db.commit() await db.commit()
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
@bp.post("/<note_id>/reminder/snooze") @bp.post("/<note_id>/reminder/snooze")
@@ -210,7 +222,7 @@ async def snooze_reminder(note_id: str):
return not_found() return not_found()
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes) note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
await db.commit() await db.commit()
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
@bp.get("/export") @bp.get("/export")
@@ -421,7 +433,7 @@ async def get_note(note_id: str):
) )
if note is None: if note is None:
return not_found() return not_found()
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
@bp.patch("/<note_id>") @bp.patch("/<note_id>")
@@ -429,9 +441,13 @@ async def get_note(note_id: str):
async def update_note(note_id: str): async def update_note(note_id: str):
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_editable(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
# Someone the note is shared with at `edit` may change its text and nothing
# else. They can already read it, so saying so is no oracle.
if note.owner_id != g.user_id and set(data) - {"body"}:
return json_error("only the owner can change that", 403)
changed = False changed = False
if isinstance(data.get("body"), str): if isinstance(data.get("body"), str):
# Version history: the first write of an editing session snapshots, not # Version history: the first write of an editing session snapshots, not
@@ -468,7 +484,7 @@ async def _commit_note(db, note: Note, text_changed: bool):
await db.refresh(note) await db.refresh(note)
if text_changed: if text_changed:
schedule_unfurls(note.id, note.body) schedule_unfurls(note.id, note.body)
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
def _serialize_revision(rev: NoteRevision) -> dict: def _serialize_revision(rev: NoteRevision) -> dict:
@@ -511,7 +527,7 @@ async def restore_revision(note_id: str, rev_id: str):
if rev is None: if rev is None:
return not_found() return not_found()
if note.body == rev.body: if note.body == rev.body:
return jsonify(await _serialize_note(db, note)) # already at this version — no-op return jsonify(await _serialize_note(db, note, g.user_id)) # already at this version — no-op
# Snapshot the CURRENT text unconditionally, so restoring is itself undoable, # Snapshot the CURRENT text unconditionally, so restoring is itself undoable,
# then apply the revision through the same path as any edit — which is what # then apply the revision through the same path as any edit — which is what
# gives a restored link its preview back. # gives a restored link its preview back.
@@ -541,7 +557,7 @@ async def set_note_labels(note_id: str):
owned = await resolve_owned_label_ids(db, label_ids, g.user_id) owned = await resolve_owned_label_ids(db, label_ids, g.user_id)
await reconcile_manual_labels(db, note, owned) await reconcile_manual_labels(db, note, owned)
await db.commit() await db.commit()
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
def _item_index(item_id: str) -> int | None: def _item_index(item_id: str) -> int | None:
@@ -565,7 +581,7 @@ async def add_item(note_id: str):
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
text = data["text"].strip() if isinstance(data.get("text"), str) else "" text = data["text"].strip() if isinstance(data.get("text"), str) else ""
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_editable(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
response = await _rewrite_body(db, note, append_item(note.body, text)) response = await _rewrite_body(db, note, append_item(note.body, text))
@@ -580,7 +596,7 @@ async def update_item(note_id: str, item_id: str):
if index is None: if index is None:
return not_found() return not_found()
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_editable(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
if index >= len(parse_items(note.body)): if index >= len(parse_items(note.body)):
@@ -600,7 +616,7 @@ async def delete_item(note_id: str, item_id: str):
if index is None: if index is None:
return not_found() return not_found()
async with session_scope() as db: async with session_scope() as db:
note = await _get_owned(db, note_id) note = await _get_editable(db, note_id)
if note is None: if note is None:
return not_found() return not_found()
if index >= len(parse_items(note.body)): if index >= len(parse_items(note.body)):
@@ -634,7 +650,7 @@ async def upload_attachment(note_id: str):
return json_error("invalid attachment id", 400) return json_error("invalid attachment id", 400)
existing = await db.scalar(select(NoteAttachment.note_id).where(NoteAttachment.id == att_id)) existing = await db.scalar(select(NoteAttachment.note_id).where(NoteAttachment.id == att_id))
if existing == note.id: if existing == note.id:
return jsonify(await _serialize_note(db, note)) # already have this blob return jsonify(await _serialize_note(db, note, g.user_id)) # already have this blob
if existing is not None: if existing is not None:
return json_error("attachment id already in use", 409) return json_error("attachment id already in use", 409)
raw = upload.stream.read() raw = upload.stream.read()
@@ -644,7 +660,7 @@ async def upload_attachment(note_id: str):
# Any file type is allowed — images render inline, everything else downloads. # Any file type is allowed — images render inline, everything else downloads.
store_attachment(db, note, raw, upload.filename, upload.content_type, att_id) store_attachment(db, note, raw, upload.filename, upload.content_type, att_id)
await db.commit() await db.commit()
return jsonify(await _serialize_note(db, note)), 201 return jsonify(await _serialize_note(db, note, g.user_id)), 201
@bp.get("/<note_id>/attachments/<att_id>") @bp.get("/<note_id>/attachments/<att_id>")
@@ -696,7 +712,7 @@ async def delete_attachment(note_id: str, att_id: str):
rel = att.path rel = att.path
await db.delete(att) await db.delete(att)
await db.commit() await db.commit()
result = await _serialize_note(db, note) result = await _serialize_note(db, note, g.user_id)
unlink_media(rel) unlink_media(rel)
return jsonify(result) return jsonify(result)
@@ -738,7 +754,7 @@ async def unfurl_link(note_id: str):
row.image_url = preview["image_url"] row.image_url = preview["image_url"]
row.site_name = preview["site_name"] row.site_name = preview["site_name"]
await db.commit() await db.commit()
return jsonify(await _serialize_note(db, note)), 201 return jsonify(await _serialize_note(db, note, g.user_id)), 201
@bp.delete("/<note_id>/previews/<preview_id>") @bp.delete("/<note_id>/previews/<preview_id>")
@@ -758,7 +774,7 @@ async def delete_preview(note_id: str, preview_id: str):
return not_found() return not_found()
await db.delete(row) await db.delete(row)
await db.commit() await db.commit()
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
@bp.post("/<note_id>/trash") @bp.post("/<note_id>/trash")
@@ -771,7 +787,7 @@ async def trash_note(note_id: str):
note.deleted_at = datetime.now(timezone.utc) note.deleted_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
@bp.post("/<note_id>/restore") @bp.post("/<note_id>/restore")
@@ -784,7 +800,7 @@ async def restore_note(note_id: str):
note.deleted_at = None note.deleted_at = None
await db.commit() await db.commit()
await db.refresh(note) await db.refresh(note)
return jsonify(await _serialize_note(db, note)) return jsonify(await _serialize_note(db, note, g.user_id))
@bp.delete("/<note_id>") @bp.delete("/<note_id>")
+24 -1
View File
@@ -15,6 +15,7 @@ import uuid
from quart import g from quart import g
from sqlalchemy import select from sqlalchemy import select
from ..acl import EDIT, visible_to_user
from ..config import Config from ..config import Config
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
@@ -85,7 +86,13 @@ def apply_filter(stmt, filter_name: str):
async def _get_owned(db, note_id: str) -> Note | None: async def _get_owned(db, note_id: str) -> Note | None:
"""Fetch a note the current user OWNS (mutations are owner-only in M1/M2). """Fetch a note the current user OWNS, for every change only an owner may make:
trash, delete, labels, reminders, pin, archive, attachments, previews, history and
sharing (#5174).
Someone the note is shared with gets None here, and so a 404, even when they can
read the note: a 403 would confirm the note exists to a caller who only has its
id (#1984).
A purged note reads as absent: the REST API must treat it as gone, so opening, A purged note reads as absent: the REST API must treat it as gone, so opening,
editing or restoring one 404s. The sync push path looks rows up directly rather editing or restoring one 404s. The sync push path looks rows up directly rather
@@ -99,6 +106,22 @@ async def _get_owned(db, note_id: str) -> Note | None:
) )
async def _get_editable(db, note_id: str) -> Note | None:
"""Fetch a note the current user may change the TEXT of: its body and its
checklist. The owner, or someone it is shared with at `edit`; a view share gets
None, and so a 404, like a stranger (#5174)."""
nid = parse_uuid(note_id)
if nid is None:
return None
return await db.scalar(
select(Note).where(
Note.id == nid,
visible_to_user("note", Note.owner_id, Note.id, g.user_id, permission=EDIT),
Note.purged_at.is_(None),
)
)
def _slugify(text: str) -> str: def _slugify(text: str) -> str:
"""A filesystem-safe slug from a note's display name (for the .md filename).""" """A filesystem-safe slug from a note's display name (for the .md filename)."""
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower()) s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
+41 -13
View File
@@ -5,10 +5,12 @@ from __future__ import annotations
from sqlalchemy import select from sqlalchemy import select
from ..acl import granted_to, shared_ids
from ..models.label import Label, NoteLabel from ..models.label import Label, NoteLabel
from ..models.note import Note from ..models.note import Note
from ..models.note_attachment import NoteAttachment from ..models.note_attachment import NoteAttachment
from ..models.note_link_preview import NoteLinkPreview from ..models.note_link_preview import NoteLinkPreview
from ..models.user import User
from .checklist import parse_items from .checklist import parse_items
@@ -104,30 +106,56 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
return result return result
async def _serialize_note(db, note: Note) -> dict: async def _sharing_for_notes(db, notes: list, viewer) -> dict:
data = note.serialize() """note_id -> how `viewer` holds the note (#5174): `permission` (owner, edit or
labels = await _labels_for_notes(db, [note.id]) view), `shared` (whether it is shared at all, as far as the viewer may know) and
data["labels"] = labels.get(note.id, []) `shared_by` (the owner, for a note someone shared with them)."""
data["items"] = items_of(note.body) foreign = [n for n in notes if n.owner_id != viewer]
attachments = await _attachments_for_notes(db, [note.id]) own_ids = [n.id for n in notes if n.owner_id == viewer]
data["attachments"] = attachments.get(note.id, []) granted = await granted_to(db, "note", [n.id for n in foreign], viewer)
previews = await _previews_for_notes(db, [note.id]) sharing = await shared_ids(db, "note", own_ids)
data["previews"] = previews.get(note.id, []) owner_ids = {n.owner_id for n in foreign}
return data names = (
dict((await db.execute(select(User.id, User.display_name).where(User.id.in_(owner_ids)))).all())
if owner_ids
else {}
)
out: dict = {}
for n in notes:
if n.owner_id == viewer:
out[n.id] = {"permission": "owner", "shared": n.id in sharing, "shared_by": None}
else:
out[n.id] = {
"permission": granted.get(n.id, "view"),
"shared": True,
"shared_by": {"id": str(n.owner_id), "display_name": names.get(n.owner_id, "")},
}
return out
async def _serialize_notes(db, notes: list) -> list: async def _serialize_note(db, note: Note, viewer=None) -> dict:
return (await _serialize_notes(db, [note], viewer))[0]
async def _serialize_notes(db, notes: list, viewer=None) -> list:
"""The API shape of each note. With `viewer`, each also says how the viewer holds
it, and a note someone shared with them comes without the owner's labels: labels
are personal, and a recipient's board files nothing under someone else's tags.
Sync passes no viewer, since its feed is the caller's own notes."""
ids = [n.id for n in notes] ids = [n.id for n in notes]
labels_map = await _labels_for_notes(db, ids) labels_map = await _labels_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids) attach_map = await _attachments_for_notes(db, ids)
preview_map = await _previews_for_notes(db, ids) preview_map = await _previews_for_notes(db, ids)
sharing = await _sharing_for_notes(db, notes, viewer) if viewer is not None else {}
out = [] out = []
for n in notes: for n in notes:
data = n.serialize() data = n.serialize()
data["labels"] = labels_map.get(n.id, []) mine = viewer is None or n.owner_id == viewer
data["labels"] = labels_map.get(n.id, []) if mine else []
data["items"] = items_of(n.body) data["items"] = items_of(n.body)
data["attachments"] = attach_map.get(n.id, []) data["attachments"] = attach_map.get(n.id, [])
data["previews"] = preview_map.get(n.id, []) data["previews"] = preview_map.get(n.id, [])
if viewer is not None:
data.update(sharing[n.id])
out.append(data) out.append(data)
return out return out
+3
View File
@@ -34,6 +34,7 @@ from .models.note import Note
from .models.note_attachment import NoteAttachment from .models.note_attachment import NoteAttachment
from .models.note_link_preview import NoteLinkPreview from .models.note_link_preview import NoteLinkPreview
from .models.note_revision import NoteRevision from .models.note_revision import NoteRevision
from .models.share import Share
from .settings import get_setting from .settings import get_setting
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -87,6 +88,8 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id)) await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id)) await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id)) await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
# Its shares too: a deleted note is shared with nobody.
await db.execute(sa_delete(Share).where(Share.resource_type == "note", Share.resource_id == note.id))
note.body = "" note.body = ""
note.display_title = "" note.display_title = ""
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was # `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
+1
View File
@@ -26,6 +26,7 @@ _ALLOWED_PARAM_KEYS = {
"has_attachment", "has_attachment",
"created_after", "created_after",
"created_before", "created_before",
"shared", # "with_me": notes other people shared with you (#5174)
"filter", "filter",
} }
+127
View File
@@ -0,0 +1,127 @@
"""Sharing a note with another member of this instance (#5174).
`acl.visible_to_user` has gated every read since M0, but nothing wrote a share. This
is the writing half: the note's owner lists, grants and revokes access, and anyone
signed in can read the member directory the Share dialog picks people from.
What a share grants is in `acl` (view, or edit: the body and checklist) and is
enforced by the note routes (`notes.helpers._get_owned` / `_get_editable`).
"""
from __future__ import annotations
import uuid
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert
from .acl import PERMISSIONS
from .auth import login_required
from .common import iso
from .db import session_scope
from .models.share import Share
from .models.user import User
from .notes.helpers import _get_owned
from .responses import json_error, not_found, parse_uuid
bp = Blueprint("shares", __name__, url_prefix="/api")
def _member(user: User) -> dict:
return {"id": str(user.id), "display_name": user.display_name, "email": user.email}
@bp.get("/users/directory")
@login_required
async def directory():
"""The people on this instance a note can be shared with: everyone but you.
Signed-in only. Each entry is a name and an email and nothing more, which is what
choosing someone takes and all a fellow member needs to know."""
async with session_scope() as db:
users = (
await db.scalars(select(User).where(User.id != g.user_id).order_by(User.display_name, User.email))
).all()
return jsonify({"members": [_member(u) for u in users]})
async def _shares_of(db, note_id: uuid.UUID) -> list[dict]:
rows = (
await db.execute(
select(Share, User)
.join(User, User.id == Share.shared_with_user_id)
.where(Share.resource_type == "note", Share.resource_id == note_id)
.order_by(Share.created_at)
)
).all()
return [
{"id": str(share.id), "member": _member(user), "permission": share.permission, "created_at": iso(share.created_at)}
for share, user in rows
]
@bp.get("/notes/<note_id>/shares")
@login_required
async def list_shares(note_id: str):
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
return jsonify({"shares": await _shares_of(db, note.id)})
@bp.post("/notes/<note_id>/shares")
@login_required
async def share_note(note_id: str):
"""Share the note with one member at `view` or `edit`. Sharing again with the same
person changes their permission rather than adding a second grant."""
data = await request.get_json(silent=True) or {}
permission = data.get("permission") or "view"
if permission not in PERMISSIONS:
return json_error("permission must be view or edit", 400)
target = parse_uuid(str(data.get("user_id") or ""))
if target is None:
return json_error("choose someone to share with", 400)
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None:
return not_found()
if target == g.user_id:
return json_error("this note is already yours", 400)
if await db.get(User, target) is None:
return json_error("that person isn't on this instance", 400)
await db.execute(
insert(Share)
.values(
id=uuid.uuid4(),
resource_type="note",
resource_id=note.id,
shared_with_user_id=target,
permission=permission,
)
.on_conflict_do_update(
index_elements=[Share.resource_type, Share.resource_id, Share.shared_with_user_id],
index_where=Share.shared_with_user_id.is_not(None),
set_={"permission": permission},
)
)
await db.commit()
return jsonify({"shares": await _shares_of(db, note.id)}), 201
@bp.delete("/notes/<note_id>/shares/<share_id>")
@login_required
async def unshare_note(note_id: str, share_id: str):
sid = parse_uuid(share_id)
async with session_scope() as db:
note = await _get_owned(db, note_id)
if note is None or sid is None:
return not_found()
share = await db.scalar(
select(Share).where(Share.id == sid, Share.resource_type == "note", Share.resource_id == note.id)
)
if share is None:
return not_found()
await db.delete(share)
await db.commit()
return jsonify({"shares": await _shares_of(db, note.id)})
+159 -1
View File
@@ -30,6 +30,7 @@ from inkwell.config import Config
from inkwell.db import dispose_engine, session_scope from inkwell.db import dispose_engine, session_scope
from inkwell.models.invite import Invite from inkwell.models.invite import Invite
from inkwell.models.password_reset import PasswordReset from inkwell.models.password_reset import PasswordReset
from inkwell.models.share import Share
from inkwell.models.label import NoteLabel from inkwell.models.label import NoteLabel
from inkwell.models.note import Note from inkwell.models.note import Note
from inkwell.models.note_attachment import NoteAttachment from inkwell.models.note_attachment import NoteAttachment
@@ -47,7 +48,7 @@ pytestmark = pytest.mark.integration
# Every table the tests touch, child-first so FKs never block the truncate. # Every table the tests touch, child-first so FKs never block the truncate.
# RESTART IDENTITY + CASCADE keeps this honest if a table gains children later. # RESTART IDENTITY + CASCADE keeps this honest if a table gains children later.
_TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, invites, password_resets, users" _TABLES = "notes, note_revisions, note_labels, note_link_previews, labels, shares, invites, password_resets, users"
@pytest_asyncio.fixture @pytest_asyncio.fixture
@@ -1179,3 +1180,160 @@ async def test_revoking_this_device_from_a_web_session_is_a_bad_request(app_clie
await _signed_in(app_client, "web") await _signed_in(app_client, "web")
resp = await app_client.delete("/api/auth/devices/self") resp = await app_client.delete("/api/auth/devices/self")
assert resp.status_code == 400 assert resp.status_code == 400
# --- Sharing a note (#5174) ---------------------------------------------------
#
# Owner, a recipient, and a stranger who is on the instance but not shared with.
async def _three_people(app_client):
"""The owner (admin, signed in on `app_client`), a recipient and a stranger, each
signed in on a client of their own. Returns (recipient, stranger, ids by name)."""
first = await _admin_with_invite(app_client)
second = (await (await app_client.post("/api/invites", json={})).get_json())["token"]
people = {}
clients = []
for name, token in (("recipient", first), ("stranger", second)):
client = create_app().test_client()
joined = await client.post(
"/api/auth/register",
json={"email": f"{name}@example.test", "password": _PASSWORD, "display_name": name.title(), "invite": token},
)
assert joined.status_code == 201
people[name] = (await joined.get_json())["id"]
clients.append(client)
return clients[0], clients[1], people
async def _owners_note(app_client, body: str = "a shared thought #idea") -> str:
resp = await app_client.post("/api/notes", json={"body": body})
assert resp.status_code == 201, await resp.get_data(as_text=True)
return (await resp.get_json())["id"]
async def _share(app_client, nid: str, user_id: str, permission: str = "view"):
return await app_client.post(f"/api/notes/{nid}/shares", json={"user_id": user_id, "permission": permission})
async def _board_ids(client, query: str = "") -> list[str]:
return [n["id"] for n in (await (await client.get(f"/api/notes?{query}")).get_json())["notes"]]
async def test_a_shared_note_reaches_the_recipient_and_no_one_else(app_client, db):
recipient, stranger, people = await _three_people(app_client)
nid = await _owners_note(app_client)
# The directory is everyone but you.
members = (await (await app_client.get("/api/users/directory")).get_json())["members"]
assert sorted(m["email"] for m in members) == ["recipient@example.test", "stranger@example.test"]
assert (await recipient.get(f"/api/notes/{nid}")).status_code == 404
shared = await _share(app_client, nid, people["recipient"])
assert shared.status_code == 201, await shared.get_data(as_text=True)
assert [(s["member"]["email"], s["permission"]) for s in (await shared.get_json())["shares"]] == [
("recipient@example.test", "view")
]
seen = await (await recipient.get(f"/api/notes/{nid}")).get_json()
assert seen["permission"] == "view"
assert seen["shared_by"]["display_name"] == "owner"
# Labels are personal: the owner's #idea doesn't come with the note.
assert seen["labels"] == []
assert nid in await _board_ids(recipient)
assert await _board_ids(recipient, "shared=with_me") == [nid]
mine = await (await app_client.get(f"/api/notes/{nid}")).get_json()
assert (mine["permission"], mine["shared"], mine["shared_by"]) == ("owner", True, None)
assert [lb["name"] for lb in mine["labels"]] == ["idea"]
assert await _board_ids(app_client, "shared=with_me") == []
# The stranger, on the same instance, sees nothing of it.
assert (await stranger.get(f"/api/notes/{nid}")).status_code == 404
assert nid not in await _board_ids(stranger)
async def test_a_view_share_writes_nothing_and_an_edit_share_writes_only_text(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client, "first line\n- [ ] milk")
await _share(app_client, nid, people["recipient"], "view")
# View: every write is a 404, the same answer a stranger gets (#1984).
writes = [
recipient.patch(f"/api/notes/{nid}", json={"body": "mine now"}),
recipient.post(f"/api/notes/{nid}/items", json={"text": "eggs"}),
recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True}),
recipient.post(f"/api/notes/{nid}/trash"),
recipient.put(f"/api/notes/{nid}/labels", json={"label_ids": []}),
recipient.get(f"/api/notes/{nid}/shares"),
recipient.post(f"/api/notes/{nid}/shares", json={"user_id": people["stranger"]}),
]
for pending in writes:
assert (await pending).status_code == 404
# Sharing again changes the permission rather than adding a second grant.
upgraded = await _share(app_client, nid, people["recipient"], "edit")
assert [s["permission"] for s in (await upgraded.get_json())["shares"]] == ["edit"]
# Edit: the text and the checklist.
edited = await recipient.patch(f"/api/notes/{nid}", json={"body": "first line, edited #fromguest\n- [ ] milk"})
assert edited.status_code == 200, await edited.get_data(as_text=True)
assert (await edited.get_json())["labels"] == []
ticked = await recipient.patch(f"/api/notes/{nid}/items/0", json={"checked": True})
assert ticked.status_code == 200
assert (await recipient.post(f"/api/notes/{nid}/items", json={"text": "eggs"})).status_code == 200
# A #tag typed into someone else's note files it under the OWNER's tags.
owner_tags = [lb["name"] for lb in (await (await app_client.get("/api/labels")).get_json())["labels"]]
assert "fromguest" in owner_tags
assert (await (await recipient.get("/api/labels")).get_json())["labels"] == []
# Everything else stays the owner's.
assert (await recipient.patch(f"/api/notes/{nid}", json={"pinned": True})).status_code == 403
assert (await recipient.patch(f"/api/notes/{nid}", json={"body": "x", "archived": True})).status_code == 403
assert (await recipient.post(f"/api/notes/{nid}/trash")).status_code == 404
assert (await recipient.get(f"/api/notes/{nid}/revisions")).status_code == 404
body = (await (await app_client.get(f"/api/notes/{nid}")).get_json())["body"]
assert body.startswith("first line, edited")
assert "- [x] milk" in body and "eggs" in body
async def test_unsharing_trashing_and_deleting_each_end_access(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)
share_id = (await (await _share(app_client, nid, people["recipient"])).get_json())["shares"][0]["id"]
left = await app_client.delete(f"/api/notes/{nid}/shares/{share_id}")
assert left.status_code == 200
assert (await left.get_json())["shares"] == []
assert (await recipient.get(f"/api/notes/{nid}")).status_code == 404
assert (await (await app_client.get(f"/api/notes/{nid}")).get_json())["shared"] is False
# Trashed by its owner, it leaves the recipient's board without reaching their Trash.
await _share(app_client, nid, people["recipient"])
assert (await app_client.post(f"/api/notes/{nid}/trash")).status_code == 200
assert nid not in await _board_ids(recipient)
assert await _board_ids(recipient, "filter=trash") == []
# Deleted for good, it is shared with nobody.
assert (await app_client.delete(f"/api/notes/{nid}")).status_code == 200
async with session_scope() as fresh:
assert await fresh.scalar(select(func.count()).select_from(Share)) == 0
async def test_a_share_names_someone_else_on_this_instance(app_client, db):
recipient, _, people = await _three_people(app_client)
nid = await _owners_note(app_client)
me = (await (await app_client.get("/api/auth/me")).get_json())["id"]
assert (await _share(app_client, nid, me)).status_code == 400
assert (await _share(app_client, nid, str(uuid.uuid4()))).status_code == 400
assert (await _share(app_client, nid, "not-an-id")).status_code == 400
assert (await _share(app_client, nid, people["recipient"], "admin")).status_code == 400
# Only the owner shares: a recipient re-sharing gets the stranger's 404.
await _share(app_client, nid, people["recipient"], "edit")
assert (await _share(recipient, nid, people["stranger"])).status_code == 404
# A share someone else's note doesn't hold can't be removed through this one.
other = await _owners_note(app_client, "another")
sid = (await (await _share(app_client, nid, people["stranger"])).get_json())["shares"][-1]["id"]
assert (await app_client.delete(f"/api/notes/{other}/shares/{sid}")).status_code == 404