Public read-only share link (no login / no OIDC) (#41) (#43)
Build image / build-and-push (push) Successful in 8s
Build image / build-and-push (push) Successful in 8s
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action. Closes #41. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #43.
This commit is contained in:
@@ -153,3 +153,63 @@ func (d *DB) DeleteGarden(ctx context.Context, id int64) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGardenByPublicToken returns the garden whose public_token matches, or
|
||||
// domain.ErrNotFound. Possession of the token is the capability, so there is no
|
||||
// owner/actor check here — the service exposes this only for the unauthenticated
|
||||
// public-read path. public_token itself is never selected into the row (it stays
|
||||
// out of gardenColumns), so it can't leak through the returned garden.
|
||||
func (d *DB) GetGardenByPublicToken(ctx context.Context, token string) (*domain.Garden, error) {
|
||||
g, err := scanGarden(d.sql.QueryRowContext(ctx,
|
||||
`SELECT `+gardenColumns+` FROM gardens WHERE public_token = ?`, token))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get garden by public token: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// GetGardenPublicToken returns the garden's current public token (nil when the
|
||||
// public link is disabled), or domain.ErrNotFound if the garden is gone.
|
||||
func (d *DB) GetGardenPublicToken(ctx context.Context, gardenID int64) (*string, error) {
|
||||
var tok sql.NullString
|
||||
err := d.sql.QueryRowContext(ctx, `SELECT public_token FROM gardens WHERE id = ?`, gardenID).Scan(&tok)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: get garden public token: %w", err)
|
||||
}
|
||||
if !tok.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return &tok.String, nil
|
||||
}
|
||||
|
||||
// SetGardenPublicToken sets the garden's public token (enabling/rotating the
|
||||
// link) or clears it with a nil token (disabling the link). It does not bump the
|
||||
// garden version — the public link is out-of-band from garden edits, so toggling
|
||||
// it must not spuriously conflict a concurrent editor. Returns domain.ErrNotFound
|
||||
// if the garden is gone.
|
||||
func (d *DB) SetGardenPublicToken(ctx context.Context, gardenID int64, token *string) error {
|
||||
var val any
|
||||
if token != nil {
|
||||
val = *token
|
||||
}
|
||||
res, err := d.sql.ExecContext(ctx,
|
||||
`UPDATE gardens SET public_token = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`,
|
||||
val, gardenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set garden public token: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set garden public token rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Per-garden public share token. When set, anyone holding the token may read the
|
||||
-- garden read-only via /api/v1/public/gardens/:token, with no login and no OIDC.
|
||||
-- NULL disables the public link. Unique (over non-NULL values) so a token maps to
|
||||
-- at most one garden; rotating the token invalidates the previous URL.
|
||||
ALTER TABLE gardens ADD COLUMN public_token TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX idx_gardens_public_token ON gardens (public_token) WHERE public_token IS NOT NULL;
|
||||
Reference in New Issue
Block a user