134 lines
4.4 KiB
Go
134 lines
4.4 KiB
Go
package store
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"errors"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// NoteShare is one person's direct grant on a note: who they are, and what they
|
||
|
|
// may do. The person is carried whole rather than by id, because every caller of
|
||
|
|
// this wants to show them — a name and an avatar colour, never a bare id.
|
||
|
|
type NoteShare struct {
|
||
|
|
User Person `json:"user"`
|
||
|
|
Role Role `json:"role"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ShareNote grants a person a role on a note, or changes the role if they already
|
||
|
|
// had one. The owner is passed so the row records who did the sharing; the caller
|
||
|
|
// has already checked that they may.
|
||
|
|
//
|
||
|
|
// Sharing a note with its own owner is refused: the owner already has everything a
|
||
|
|
// share could give, and a row saying otherwise is a contradiction waiting to
|
||
|
|
// confuse the access check.
|
||
|
|
func (db *DB) ShareNote(ctx context.Context, noteID, userID string, role Role, byUserID string) error {
|
||
|
|
if !role.Valid() || role == RoleOwner {
|
||
|
|
return errors.New("store: a note can be shared as viewer or editor")
|
||
|
|
}
|
||
|
|
var createdBy sql.NullString
|
||
|
|
if err := db.QueryRowContext(ctx,
|
||
|
|
`SELECT COALESCE(created_by, '') FROM notes WHERE id = ? AND deleted_at IS NULL`,
|
||
|
|
noteID).Scan(&createdBy.String); errors.Is(err, sql.ErrNoRows) {
|
||
|
|
return ErrNotFound
|
||
|
|
} else if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if createdBy.String == userID {
|
||
|
|
return errors.New("store: a note is already the owner's")
|
||
|
|
}
|
||
|
|
_, err := db.ExecContext(ctx, `
|
||
|
|
INSERT INTO note_shares (note_id, user_id, role, created_by, created_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?)
|
||
|
|
ON CONFLICT (note_id, user_id) DO UPDATE SET role = excluded.role`,
|
||
|
|
noteID, userID, role, byUserID, time.Now().Unix())
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// UnshareNote removes a person's grant. Removing one that is not there is not an
|
||
|
|
// error: the end state the caller asked for — this person cannot see this note —
|
||
|
|
// is the same either way.
|
||
|
|
func (db *DB) UnshareNote(ctx context.Context, noteID, userID string) error {
|
||
|
|
_, err := db.ExecContext(ctx,
|
||
|
|
`DELETE FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListNoteShares is who a note is shared with, each with their role, ordered by
|
||
|
|
// name so the list under a note does not reshuffle every time it is opened.
|
||
|
|
func (db *DB) ListNoteShares(ctx context.Context, noteID string) ([]NoteShare, error) {
|
||
|
|
rows, err := db.QueryContext(ctx, `
|
||
|
|
SELECT u.id, u.name, u.avatar_color, s.role
|
||
|
|
FROM note_shares s
|
||
|
|
JOIN users u ON u.id = s.user_id
|
||
|
|
WHERE s.note_id = ?
|
||
|
|
ORDER BY lower(u.name)`, noteID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var out []NoteShare
|
||
|
|
for rows.Next() {
|
||
|
|
var s NoteShare
|
||
|
|
if err := rows.Scan(&s.User.ID, &s.User.Name, &s.User.AvatarColor, &s.Role); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out = append(out, s)
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// NoteShareRole is the role a person holds on a note through a direct share, if
|
||
|
|
// any. The bool is whether a share exists at all — distinct from a share whose
|
||
|
|
// role could not be read — so a caller does not have to treat "no share" as an
|
||
|
|
// error.
|
||
|
|
func (db *DB) NoteShareRole(ctx context.Context, noteID, userID string) (Role, bool, error) {
|
||
|
|
var role Role
|
||
|
|
err := db.QueryRowContext(ctx,
|
||
|
|
`SELECT role FROM note_shares WHERE note_id = ? AND user_id = ?`, noteID, userID).Scan(&role)
|
||
|
|
if errors.Is(err, sql.ErrNoRows) {
|
||
|
|
return "", false, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return "", false, err
|
||
|
|
}
|
||
|
|
if !role.Valid() {
|
||
|
|
// A role the database holds but this build does not understand is no access,
|
||
|
|
// never a guess — the same rule project roles follow.
|
||
|
|
return "", false, nil
|
||
|
|
}
|
||
|
|
return role, true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// NotesSharedWith returns the ids, among those given, that are shared directly with
|
||
|
|
// the user. It answers the Delt med mig question in one query rather than one per
|
||
|
|
// note, and takes the ids it is scoping so it never has to load a person's whole
|
||
|
|
// world to mark a single page.
|
||
|
|
func (db *DB) NotesSharedWith(ctx context.Context, userID string, noteIDs []string) (map[string]bool, error) {
|
||
|
|
out := map[string]bool{}
|
||
|
|
if len(noteIDs) == 0 {
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
args := make([]any, 0, len(noteIDs)+1)
|
||
|
|
args = append(args, userID)
|
||
|
|
for _, id := range noteIDs {
|
||
|
|
args = append(args, id)
|
||
|
|
}
|
||
|
|
rows, err := db.QueryContext(ctx,
|
||
|
|
`SELECT note_id FROM note_shares WHERE user_id = ? AND note_id IN (`+placeholders(len(noteIDs))+`)`,
|
||
|
|
args...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
for rows.Next() {
|
||
|
|
var id string
|
||
|
|
if err := rows.Scan(&id); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out[id] = true
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|