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
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:
@@ -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")
|
||||
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
|
||||
import type { TitleEntry } from "../stores/titles";
|
||||
import type { User } from "../stores/session";
|
||||
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.";
|
||||
|
||||
@@ -91,4 +91,13 @@ export const local: Repo = {
|
||||
remove: (id) => invoke<void>("saved_filters_remove", { id }),
|
||||
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)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
source: string;
|
||||
imported: number;
|
||||
@@ -116,6 +134,15 @@ export interface NotesRepo {
|
||||
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 {
|
||||
list(): Promise<SavedFilter[]>;
|
||||
create(name: string, params: NoteFacets): Promise<SavedFilter>;
|
||||
@@ -130,4 +157,5 @@ export interface Repo {
|
||||
labels: LabelsRepo;
|
||||
notes: NotesRepo;
|
||||
savedFilters: SavedFiltersRepo;
|
||||
shares: SharesRepo;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { PublicConfig } from "../stores/config";
|
||||
import type {
|
||||
DeviceToken,
|
||||
ImportResult,
|
||||
Member,
|
||||
NoteChanges,
|
||||
NoteShare,
|
||||
NoteCreateInput,
|
||||
NoteListQuery,
|
||||
ChecklistItemChanges,
|
||||
@@ -35,6 +37,7 @@ function notesQuery(q: NoteListQuery): string {
|
||||
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_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);
|
||||
return params.toString();
|
||||
}
|
||||
@@ -115,4 +118,13 @@ export const rest: Repo = {
|
||||
remove: (id) => api.del<void>(`/api/saved-filters/${id}`),
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ import Icon from "./Icon.vue";
|
||||
import ExportNotes from "./ExportNotes.vue";
|
||||
import ImportNotes from "./ImportNotes.vue";
|
||||
import LabelsModal from "./LabelsModal.vue";
|
||||
import ShareDialog from "./ShareDialog.vue";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
import { useLensName } from "../composables/useLensName";
|
||||
import { facetsToQuery } from "../notes/facets";
|
||||
@@ -552,6 +553,7 @@ async function signOut() {
|
||||
<LabelsModal v-if="managing" @close="managing = false" />
|
||||
|
||||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||||
<ShareDialog v-if="ui.shareNoteId" :note-id="ui.shareNoteId" @close="ui.closeShare()" />
|
||||
|
||||
<BaseModal
|
||||
v-if="showShortcuts"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { facetCount, facetsFromQuery, facetsToQuery } from "../notes/facets";
|
||||
import { addLocalDays, formatLocalDay, parseLocalDate } from "../notes/datetime";
|
||||
import Icon from "./Icon.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
|
||||
// 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
|
||||
@@ -43,6 +44,12 @@ function toggleReminder() {
|
||||
function toggleAttachment() {
|
||||
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) {
|
||||
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">
|
||||
Has attachment
|
||||
</button>
|
||||
<button
|
||||
v-if="sharingAvailable"
|
||||
type="button"
|
||||
:class="[chipBase, facets.shared ? chipOn : chipOff]"
|
||||
@click="toggleShared"
|
||||
>
|
||||
Shared with me
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from "../composables/useCardDrag";
|
||||
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
||||
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 emit = defineEmits<{
|
||||
@@ -32,6 +35,21 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
const notes = useNotesStore();
|
||||
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
|
||||
// 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
|
||||
// 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. ---
|
||||
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 dragOver = computed(() => overId.value === props.note.id);
|
||||
|
||||
@@ -308,7 +327,12 @@ const tagColors = computed<Record<string, string>>(() => {
|
||||
blank and the link is never unreachable. -->
|
||||
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
||||
<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>
|
||||
<p
|
||||
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
|
||||
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
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="
|
||||
@@ -424,7 +458,9 @@ const tagColors = computed<Record<string, string>>(() => {
|
||||
<Icon name="grip" />
|
||||
</button>
|
||||
|
||||
<!-- Every action here is the owner's, so a shared note has no action set. -->
|
||||
<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"
|
||||
>
|
||||
<template v-if="note.trashed">
|
||||
@@ -441,7 +477,17 @@ const tagColors = computed<Record<string, string>>(() => {
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</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
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
|
||||
@@ -11,6 +11,9 @@ import { takeMorphOrigin } from "../composables/useEditorMorph";
|
||||
import { prefersReducedMotion } from "../composables/useReducedMotion";
|
||||
import type { Note, NoteLabel, NoteRevision } from "../stores/notes";
|
||||
import { labelChipClasses } from "../notes/colors";
|
||||
import { canEditText, isOwnNote } from "../notes/sharing";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import { isDesktop } from "../desktop/bridge";
|
||||
import {
|
||||
afterEnter,
|
||||
type EditorBlock,
|
||||
@@ -114,6 +117,19 @@ const liveNote = computed<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).
|
||||
watch(
|
||||
() => 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.
|
||||
*/
|
||||
function onProseBlur(index: number): void {
|
||||
if (!editable.value) return;
|
||||
const promoted = promoteTasks(blocks.value, index);
|
||||
if (promoted === blocks.value) return;
|
||||
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. */
|
||||
function onTaskEnter(index: number): void {
|
||||
if (!editable.value) return;
|
||||
const next = afterEnter(blocks.value, index);
|
||||
blocks.value = next.blocks;
|
||||
void focusBlock(next.focus);
|
||||
@@ -383,6 +401,7 @@ function onTaskEnter(index: number): void {
|
||||
* only one of them can offer.
|
||||
*/
|
||||
function onTaskBackspace(index: number, e: KeyboardEvent): void {
|
||||
if (!editable.value) return;
|
||||
const el = e.target as HTMLInputElement;
|
||||
if (el.value !== "" || el.selectionStart !== 0) return;
|
||||
e.preventDefault();
|
||||
@@ -487,6 +506,7 @@ async function onFileChange(e: Event) {
|
||||
input.value = "";
|
||||
}
|
||||
async function onPaste(e: ClipboardEvent) {
|
||||
if (!own.value) return;
|
||||
const item = Array.from(e.clipboardData?.items ?? []).find((i) => i.type.startsWith("image/"));
|
||||
const file = item?.getAsFile();
|
||||
if (file) {
|
||||
@@ -563,12 +583,17 @@ function revPreview(rev: NoteRevision): string {
|
||||
@paste="onPaste"
|
||||
>
|
||||
<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">
|
||||
<template v-for="att in liveNote.attachments" :key="att.id">
|
||||
<!-- Image → inline thumbnail -->
|
||||
<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" />
|
||||
<button
|
||||
v-if="own"
|
||||
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"
|
||||
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>
|
||||
<button
|
||||
v-if="own"
|
||||
type="button"
|
||||
class="text-neutral-400 hover:text-red-500"
|
||||
aria-label="Remove attachment"
|
||||
@@ -603,6 +629,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
|
||||
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
|
||||
<button
|
||||
v-if="own"
|
||||
type="button"
|
||||
class="ml-1 text-neutral-400 hover:text-red-500"
|
||||
aria-label="Remove attachment"
|
||||
@@ -631,7 +658,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
v-for="p in liveNote.previews"
|
||||
:key="p.id"
|
||||
:preview="p"
|
||||
:removable="!liveNote.trashed"
|
||||
:removable="!liveNote.trashed && own"
|
||||
@remove="notes.deletePreview(liveNote.id, p.id)"
|
||||
/>
|
||||
</div>
|
||||
@@ -646,6 +673,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
type="checkbox"
|
||||
class="h-4 w-4 shrink-0 accent-brand"
|
||||
:checked="block.checked"
|
||||
:disabled="!editable"
|
||||
:aria-label="block.text || 'Checklist item'"
|
||||
@change="setChecked(i, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
@@ -653,6 +681,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
:ref="(el) => setBlockEl(block.id, el)"
|
||||
:value="block.text"
|
||||
type="text"
|
||||
:readonly="!editable"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm leading-relaxed outline-none"
|
||||
:class="block.checked ? 'text-neutral-400 line-through' : ''"
|
||||
@input="setText(i, ($event.target as HTMLInputElement).value)"
|
||||
@@ -660,6 +689,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
@keydown.backspace="onTaskBackspace(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="editable"
|
||||
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"
|
||||
aria-label="Delete item"
|
||||
@@ -673,7 +703,8 @@ function revPreview(rev: NoteRevision): string {
|
||||
:ref="(el) => setBlockEl(block.id, el)"
|
||||
:value="block.text"
|
||||
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"
|
||||
@input="onProseInput(i, $event)"
|
||||
@keydown="onProseKeydown"
|
||||
@@ -707,7 +738,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</span>
|
||||
</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" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
@@ -725,7 +756,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
</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>
|
||||
<select
|
||||
: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 gap-0.5">
|
||||
<button
|
||||
v-if="richEnabled && !liveNote.trashed"
|
||||
v-if="richEnabled && !liveNote.trashed && own"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Attach a file"
|
||||
@@ -796,7 +827,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||
<button
|
||||
v-if="!liveNote.trashed"
|
||||
v-if="!liveNote.trashed && editable"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Add a checklist"
|
||||
@@ -806,12 +837,23 @@ function revPreview(rev: NoteRevision): string {
|
||||
<Icon name="checkbox" />
|
||||
</button>
|
||||
<LabelPicker
|
||||
v-if="richEnabled && !liveNote.trashed"
|
||||
v-if="richEnabled && !liveNote.trashed && own"
|
||||
:model-value="labelList"
|
||||
@update:model-value="onLabelsChange"
|
||||
/>
|
||||
<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"
|
||||
class="icon-btn"
|
||||
:class="showHistory ? 'text-brand-700 dark:text-brand' : ''"
|
||||
@@ -822,7 +864,7 @@ function revPreview(rev: NoteRevision): string {
|
||||
>
|
||||
<Icon name="history" />
|
||||
</button>
|
||||
<template v-if="!isCreate && !liveNote.trashed">
|
||||
<template v-if="!isCreate && !liveNote.trashed && own">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
|
||||
@@ -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>
|
||||
@@ -22,6 +22,7 @@ export function facetsFromQuery(q: LocationQuery): NoteFacets {
|
||||
if (after) f.created_after = after;
|
||||
const before = one(q.created_before);
|
||||
if (before) f.created_before = before;
|
||||
if (one(q.shared) === "with_me") f.shared = "with_me";
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ export function facetsToQuery(f: NoteFacets): LocationQueryRaw {
|
||||
if (f.has_attachment) q.has_attachment = "true";
|
||||
if (f.created_after) q.created_after = f.created_after;
|
||||
if (f.created_before) q.created_before = f.created_before;
|
||||
if (f.shared) q.shared = f.shared;
|
||||
return q;
|
||||
}
|
||||
|
||||
@@ -44,5 +46,6 @@ export function facetCount(f: NoteFacets): number {
|
||||
if (f.has_reminder) n++;
|
||||
if (f.has_attachment) n++;
|
||||
if (f.created_after || f.created_before) n++;
|
||||
if (f.shared) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -13,8 +13,14 @@ export interface NoteFacets {
|
||||
has_attachment?: boolean;
|
||||
created_after?: 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -86,6 +92,13 @@ export interface Note {
|
||||
previews: LinkPreview[];
|
||||
created_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", () => {
|
||||
@@ -108,6 +121,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
const inView =
|
||||
v === "trash" ? n.trashed : v === "archived" ? !n.trashed && n.archived : !n.trashed && !n.archived;
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
@@ -262,9 +276,16 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
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 {
|
||||
items,
|
||||
loading,
|
||||
setShared,
|
||||
view,
|
||||
activeLabel,
|
||||
activeFacets,
|
||||
|
||||
@@ -53,7 +53,20 @@ export const useUiStore = defineStore("ui", () => {
|
||||
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 {
|
||||
shareNoteId,
|
||||
openShare,
|
||||
closeShare,
|
||||
composeTick,
|
||||
composeSeed,
|
||||
requestCompose,
|
||||
|
||||
+49
-1
@@ -7,12 +7,21 @@ from sqlalchemy import ColumnElement, exists, or_, select
|
||||
from .models.group import GroupMember
|
||||
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(
|
||||
resource_type: str,
|
||||
owner_column,
|
||||
resource_id_column,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
permission: str | None = None,
|
||||
) -> ColumnElement[bool]:
|
||||
"""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
|
||||
(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))
|
||||
|
||||
@@ -30,17 +42,53 @@ def visible_to_user(
|
||||
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``).
|
||||
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)
|
||||
granted = [Share.permission == permission] if permission is not None else []
|
||||
|
||||
direct_share = exists().where(
|
||||
Share.resource_type == resource_type,
|
||||
Share.resource_id == resource_id_column,
|
||||
Share.shared_with_user_id == user_id,
|
||||
*granted,
|
||||
)
|
||||
group_share = exists().where(
|
||||
Share.resource_type == resource_type,
|
||||
Share.resource_id == resource_id_column,
|
||||
Share.shared_with_group_id.in_(user_group_ids),
|
||||
*granted,
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -24,6 +24,7 @@ from .retention import run_sweeper
|
||||
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_api import bp as settings_bp
|
||||
from .shares_api import bp as shares_bp
|
||||
from .sync import bp as sync_bp, protocol_advertisement
|
||||
|
||||
# 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(invites_bp)
|
||||
app.register_blueprint(accounts_bp)
|
||||
app.register_blueprint(shares_bp)
|
||||
app.register_blueprint(sync_bp)
|
||||
app.register_blueprint(saved_filters_bp)
|
||||
app.register_blueprint(client_bp)
|
||||
|
||||
@@ -49,6 +49,7 @@ from .helpers import (
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
VALID_FILTERS,
|
||||
_attachment_ext,
|
||||
_get_editable,
|
||||
_get_owned,
|
||||
_header_filename,
|
||||
_safe_filename,
|
||||
@@ -119,9 +120,18 @@ async def list_notes():
|
||||
after_param = request.args.get("created_after")
|
||||
before_param = request.args.get("created_before")
|
||||
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:
|
||||
stmt = select(Note).where(visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
||||
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:
|
||||
lid = parse_uuid(raw_label)
|
||||
if lid is None:
|
||||
@@ -156,7 +166,7 @@ async def list_notes():
|
||||
else:
|
||||
stmt = stmt.order_by(Note.pinned.desc(), Note.position.desc(), Note.updated_at.desc())
|
||||
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")
|
||||
@@ -166,14 +176,16 @@ async def list_reminders():
|
||||
stmt = (
|
||||
select(Note)
|
||||
.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.remind_at.is_not(None),
|
||||
)
|
||||
.order_by(Note.remind_at.asc())
|
||||
)
|
||||
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")
|
||||
@@ -191,7 +203,7 @@ async def complete_reminder(note_id: str):
|
||||
note.remind_at = None
|
||||
note.recurrence = None
|
||||
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")
|
||||
@@ -210,7 +222,7 @@ async def snooze_reminder(note_id: str):
|
||||
return not_found()
|
||||
note.remind_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
return jsonify(await _serialize_note(db, note, g.user_id))
|
||||
|
||||
|
||||
@bp.get("/export")
|
||||
@@ -421,7 +433,7 @@ async def get_note(note_id: str):
|
||||
)
|
||||
if note is None:
|
||||
return not_found()
|
||||
return jsonify(await _serialize_note(db, note))
|
||||
return jsonify(await _serialize_note(db, note, g.user_id))
|
||||
|
||||
|
||||
@bp.patch("/<note_id>")
|
||||
@@ -429,9 +441,13 @@ async def get_note(note_id: str):
|
||||
async def update_note(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
note = await _get_editable(db, note_id)
|
||||
if note is None:
|
||||
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
|
||||
if isinstance(data.get("body"), str):
|
||||
# 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)
|
||||
if text_changed:
|
||||
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:
|
||||
@@ -511,7 +527,7 @@ async def restore_revision(note_id: str, rev_id: str):
|
||||
if rev is None:
|
||||
return not_found()
|
||||
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,
|
||||
# then apply the revision through the same path as any edit — which is what
|
||||
# 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)
|
||||
await reconcile_manual_labels(db, note, owned)
|
||||
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:
|
||||
@@ -565,7 +581,7 @@ async def add_item(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
text = data["text"].strip() if isinstance(data.get("text"), str) else ""
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
note = await _get_editable(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
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:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
note = await _get_editable(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
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:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
note = await _get_editable(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
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)
|
||||
existing = await db.scalar(select(NoteAttachment.note_id).where(NoteAttachment.id == att_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:
|
||||
return json_error("attachment id already in use", 409)
|
||||
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.
|
||||
store_attachment(db, note, raw, upload.filename, upload.content_type, att_id)
|
||||
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>")
|
||||
@@ -696,7 +712,7 @@ async def delete_attachment(note_id: str, att_id: str):
|
||||
rel = att.path
|
||||
await db.delete(att)
|
||||
await db.commit()
|
||||
result = await _serialize_note(db, note)
|
||||
result = await _serialize_note(db, note, g.user_id)
|
||||
unlink_media(rel)
|
||||
return jsonify(result)
|
||||
|
||||
@@ -738,7 +754,7 @@ async def unfurl_link(note_id: str):
|
||||
row.image_url = preview["image_url"]
|
||||
row.site_name = preview["site_name"]
|
||||
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>")
|
||||
@@ -758,7 +774,7 @@ async def delete_preview(note_id: str, preview_id: str):
|
||||
return not_found()
|
||||
await db.delete(row)
|
||||
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")
|
||||
@@ -771,7 +787,7 @@ async def trash_note(note_id: str):
|
||||
note.deleted_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
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")
|
||||
@@ -784,7 +800,7 @@ async def restore_note(note_id: str):
|
||||
note.deleted_at = None
|
||||
await db.commit()
|
||||
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>")
|
||||
|
||||
@@ -15,6 +15,7 @@ import uuid
|
||||
from quart import g
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..acl import EDIT, visible_to_user
|
||||
from ..config import Config
|
||||
from ..models.note import Note
|
||||
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:
|
||||
"""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,
|
||||
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:
|
||||
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
|
||||
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
|
||||
|
||||
@@ -5,10 +5,12 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..acl import granted_to, shared_ids
|
||||
from ..models.label import Label, NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
from ..models.user import User
|
||||
from .checklist import parse_items
|
||||
|
||||
|
||||
@@ -104,30 +106,56 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
|
||||
return result
|
||||
|
||||
|
||||
async def _serialize_note(db, note: Note) -> dict:
|
||||
data = note.serialize()
|
||||
labels = await _labels_for_notes(db, [note.id])
|
||||
data["labels"] = labels.get(note.id, [])
|
||||
data["items"] = items_of(note.body)
|
||||
attachments = await _attachments_for_notes(db, [note.id])
|
||||
data["attachments"] = attachments.get(note.id, [])
|
||||
previews = await _previews_for_notes(db, [note.id])
|
||||
data["previews"] = previews.get(note.id, [])
|
||||
return data
|
||||
async def _sharing_for_notes(db, notes: list, viewer) -> dict:
|
||||
"""note_id -> how `viewer` holds the note (#5174): `permission` (owner, edit or
|
||||
view), `shared` (whether it is shared at all, as far as the viewer may know) and
|
||||
`shared_by` (the owner, for a note someone shared with them)."""
|
||||
foreign = [n for n in notes if n.owner_id != viewer]
|
||||
own_ids = [n.id for n in notes if n.owner_id == viewer]
|
||||
granted = await granted_to(db, "note", [n.id for n in foreign], viewer)
|
||||
sharing = await shared_ids(db, "note", own_ids)
|
||||
owner_ids = {n.owner_id for n in foreign}
|
||||
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]
|
||||
labels_map = await _labels_for_notes(db, ids)
|
||||
|
||||
attach_map = await _attachments_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 = []
|
||||
for n in notes:
|
||||
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["attachments"] = attach_map.get(n.id, [])
|
||||
data["previews"] = preview_map.get(n.id, [])
|
||||
if viewer is not None:
|
||||
data.update(sharing[n.id])
|
||||
out.append(data)
|
||||
return out
|
||||
|
||||
@@ -34,6 +34,7 @@ from .models.note import Note
|
||||
from .models.note_attachment import NoteAttachment
|
||||
from .models.note_link_preview import NoteLinkPreview
|
||||
from .models.note_revision import NoteRevision
|
||||
from .models.share import Share
|
||||
from .settings import get_setting
|
||||
|
||||
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(NoteLinkPreview).where(NoteLinkPreview.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.display_title = ""
|
||||
# `deleted_at` deliberately SURVIVES. It's still true — that is when the note was
|
||||
|
||||
@@ -26,6 +26,7 @@ _ALLOWED_PARAM_KEYS = {
|
||||
"has_attachment",
|
||||
"created_after",
|
||||
"created_before",
|
||||
"shared", # "with_me": notes other people shared with you (#5174)
|
||||
"filter",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -30,6 +30,7 @@ from inkwell.config import Config
|
||||
from inkwell.db import dispose_engine, session_scope
|
||||
from inkwell.models.invite import Invite
|
||||
from inkwell.models.password_reset import PasswordReset
|
||||
from inkwell.models.share import Share
|
||||
from inkwell.models.label import NoteLabel
|
||||
from inkwell.models.note import Note
|
||||
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.
|
||||
# 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
|
||||
@@ -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")
|
||||
resp = await app_client.delete("/api/auth/devices/self")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user