Add read-only sharing, API tokens and OpenAPI spec
- Share a note as a live read-only page at /s/{token} with optional password and expiry
- Share page renders with the shared parser (TOC, footnotes, math, Mermaid, code highlighting)
and only serves images from the note's own .assets directory
- Shares follow renames made through the API or externally
- Manage shares and API tokens from the command palette and file tree
- Accept read/write API tokens as Bearer credentials; management endpoints require a session
- Serve a hand-written openapi.json, checked against the route table in tests
This commit is contained in:
1 parent
7b076346a4
commit
5ac01894e6
26 files changed
+3147
-52
No files matched your search
+133
-1
@@ -1,4 +1,4 @@
|
||||
// Package store 保存应用自身的数据(设置、会话)。笔记内容不在这里,见 vault。
|
||||
// Package store 保存应用自身的数据(设置、会话、分享、API 令牌)。笔记内容不在这里,见 vault。
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -22,6 +22,23 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
-- 分享链接中的令牌明文保存:管理列表需要随时复制链接,令牌本身即访问凭据,与笔记同等敏感
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
token TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS shares_path ON shares(path);
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
scope TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
@@ -93,3 +110,118 @@ func (s *Store) DeleteExpiredSessions() error {
|
||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 分享 ----
|
||||
|
||||
// Share 为一个只读分享链接。时间均为 Unix 毫秒,ExpiresAt 为 0 表示永久有效
|
||||
type Share struct {
|
||||
Token string `json:"token"`
|
||||
Path string `json:"path"`
|
||||
PasswordHash string `json:"-"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateShare(sh Share) error {
|
||||
_, err := s.db.Exec(`INSERT INTO shares (token, path, password_hash, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
sh.Token, sh.Path, sh.PasswordHash, sh.CreatedAt, sh.ExpiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetShare 按令牌读取分享;不存在时 ok 为 false
|
||||
func (s *Store) GetShare(token string) (sh Share, ok bool, err error) {
|
||||
err = s.db.QueryRow(`SELECT token, path, password_hash, created_at, expires_at FROM shares WHERE token = ?`, token).
|
||||
Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Share{}, false, nil
|
||||
}
|
||||
return sh, err == nil, err
|
||||
}
|
||||
|
||||
// Shares 列出全部分享,最新的在前
|
||||
func (s *Store) Shares() ([]Share, error) {
|
||||
rows, err := s.db.Query(`SELECT token, path, password_hash, created_at, expires_at FROM shares ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Share{}
|
||||
for rows.Next() {
|
||||
var sh Share
|
||||
if err := rows.Scan(&sh.Token, &sh.Path, &sh.PasswordHash, &sh.CreatedAt, &sh.ExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sh)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteShare 返回是否删除了记录
|
||||
func (s *Store) DeleteShare(token string) (bool, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM shares WHERE token = ?`, token)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
|
||||
// MoveShares 让分享跟随笔记移动:from 为笔记时只改它自己,为文件夹时改其下所有笔记
|
||||
func (s *Store) MoveShares(from, to string) error {
|
||||
_, err := s.db.Exec(`UPDATE shares SET path = CASE WHEN path = ?1 THEN ?2 ELSE ?2 || substr(path, length(?1) + 1) END
|
||||
WHERE path = ?1 OR substr(path, 1, length(?1) + 1) = ?1 || '/'`, from, to)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- API 令牌 ----
|
||||
|
||||
// APIToken 为自动化调用的访问令牌,只保存哈希。Scope 为 read 或 write
|
||||
type APIToken struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"-"`
|
||||
Scope string `json:"scope"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
LastUsedAt int64 `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIToken(t APIToken) error {
|
||||
_, err := s.db.Exec(`INSERT INTO api_tokens (id, name, token_hash, scope, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
t.ID, t.Name, t.Hash, t.Scope, t.CreatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) APITokens() ([]APIToken, error) {
|
||||
rows, err := s.db.Query(`SELECT id, name, scope, created_at, last_used_at FROM api_tokens ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []APIToken{}
|
||||
for rows.Next() {
|
||||
var t APIToken
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &t.LastUsedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UseAPIToken 按哈希查找令牌并记录使用时间;不存在时 ok 为 false
|
||||
func (s *Store) UseAPIToken(hash string, now int64) (scope string, ok bool, err error) {
|
||||
err = s.db.QueryRow(`UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ? RETURNING scope`, now, hash).Scan(&scope)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, nil
|
||||
}
|
||||
return scope, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAPIToken(id string) (bool, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM api_tokens WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n == 1, err
|
||||
}
|
||||
Reference in new issue
Block a user