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:
eezd committed 2026-10-17 15:21:47 +08:00
1 parent 7b076346a4
commit 5ac01894e6
26 files changed
+3147 -52

No files matched your search

+98 -1
View File
@@ -1,7 +1,8 @@
// Package auth 实现单用户登录:Argon2id 密码哈希、会话令牌与登录限速。
// Package auth 实现单用户登录(Argon2id 密码哈希、会话令牌、登录限速),以及分享密码与 API 令牌的校验。
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
@@ -20,6 +21,8 @@ import (
const (
passwordKey = "auth.password"
shareSecretKey = "share.secret"
apiTokenPrefix = "rm_"
SessionTTL = 30 * 24 * time.Hour
MinPasswordLen = 8
@@ -39,6 +42,7 @@ var (
ErrWeakPassword = fmt.Errorf("密码至少 %d 位", MinPasswordLen)
ErrBadCredentials = errors.New("密码错误")
ErrRateLimited = errors.New("尝试次数过多,请稍后再试")
ErrBadScope = errors.New("权限范围只能是 read 或 write")
)
type Manager struct {
@@ -135,6 +139,92 @@ func (m *Manager) Validate(token string) (bool, error) {
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
// ---- 分享 ----
// HashSharePassword 为分享密码生成哈希;空密码表示不设密码,返回空字符串
func HashSharePassword(password string) string {
if password == "" {
return ""
}
return hashPassword(password)
}
// CheckSharePassword 校验分享密码,与登录共用按来源的失败限速
func (m *Manager) CheckSharePassword(ip, hash, password string) error {
if !m.allow(ip) {
return ErrRateLimited
}
ok, err := verifyPassword(hash, password)
if err != nil {
return err
}
if !ok {
m.recordFailure(ip)
return ErrBadCredentials
}
return nil
}
// ShareCookie 生成输入分享密码后发给访客的凭据:绑定分享令牌与密码哈希,撤销分享即失效
func (m *Manager) ShareCookie(token, passwordHash string) (string, error) {
secret, err := m.shareSecret()
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(token + "\x00" + passwordHash))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
func (m *Manager) ValidShareCookie(cookie, token, passwordHash string) (bool, error) {
want, err := m.ShareCookie(token, passwordHash)
if err != nil {
return false, err
}
return hmac.Equal([]byte(cookie), []byte(want)), nil
}
// shareSecret 读取签名密钥,首次使用时生成并保存
func (m *Manager) shareSecret() ([]byte, error) {
v, ok, err := m.store.Setting(shareSecretKey)
if err != nil {
return nil, err
}
if !ok {
if _, err := m.store.SetSettingIfAbsent(shareSecretKey, randomToken()); err != nil {
return nil, err
}
// 并发首次生成时以先写入的为准
if v, _, err = m.store.Setting(shareSecretKey); err != nil {
return nil, err
}
}
return base64.RawURLEncoding.DecodeString(v)
}
// ---- API 令牌 ----
// CreateAPIToken 创建令牌,返回只显示这一次的明文
func (m *Manager) CreateAPIToken(name, scope string) (string, store.APIToken, error) {
if scope != "read" && scope != "write" {
return "", store.APIToken{}, ErrBadScope
}
plain := apiTokenPrefix + randomToken()
t := store.APIToken{ID: randomCode(), Name: name, Hash: hashToken(plain), Scope: scope, CreatedAt: time.Now().UnixMilli()}
if err := m.store.CreateAPIToken(t); err != nil {
return "", store.APIToken{}, err
}
return plain, t, nil
}
// ValidateAPIToken 返回令牌的权限范围;无效时 ok 为 false
func (m *Manager) ValidateAPIToken(plain string) (scope string, ok bool, err error) {
if !strings.HasPrefix(plain, apiTokenPrefix) {
return "", false, nil
}
return m.store.UseAPIToken(hashToken(plain), time.Now().UnixMilli())
}
// ---- 限速:同一来源在时间窗口内失败过多则拒绝 ----
func (m *Manager) allow(ip string) bool {
@@ -204,6 +294,13 @@ func randomToken() string {
return base64.RawURLEncoding.EncodeToString(b)
}
// NewShareToken 生成分享链接中的令牌(128 位随机数,比会话令牌短,便于复制)
func NewShareToken() string {
b := make([]byte, 16)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])