Implement Phase 4: gRPC sync service
- Proto definitions (engpad.v1.EngPadSync) with 6 RPCs - Generated Go gRPC code - Auth interceptor: username/password from metadata - SyncNotebook: upsert with full page/stroke replacement in a tx - DeleteNotebook, ListNotebooks handlers - Share link RPCs: CreateShareLink, RevokeShareLink, ListShareLinks - Share link token management (32-byte random, optional expiry) - gRPC server with TLS 1.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
105
internal/share/share.go
Normal file
105
internal/share/share.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tokenBytes = 32
|
||||
|
||||
type LinkInfo struct {
|
||||
Token string
|
||||
URL string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// CreateLink generates a shareable link for a notebook.
|
||||
func CreateLink(database *sql.DB, notebookID int64, expiry time.Duration, baseURL string) (string, *time.Time, error) {
|
||||
raw := make([]byte, tokenBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
|
||||
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(raw)
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
var expiresAt *int64
|
||||
var expiresTime *time.Time
|
||||
if expiry > 0 {
|
||||
ea := time.Now().Add(expiry).UnixMilli()
|
||||
expiresAt = &ea
|
||||
t := time.UnixMilli(ea)
|
||||
expiresTime = &t
|
||||
}
|
||||
|
||||
_, err := database.Exec(
|
||||
"INSERT INTO share_links (notebook_id, token, expires_at, created_at) VALUES (?, ?, ?, ?)",
|
||||
notebookID, token, expiresAt, now,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("insert share link: %w", err)
|
||||
}
|
||||
|
||||
return token, expiresTime, nil
|
||||
}
|
||||
|
||||
// ValidateLink checks if a token is valid and returns the notebook ID.
|
||||
func ValidateLink(database *sql.DB, token string) (int64, error) {
|
||||
var notebookID int64
|
||||
var expiresAt *int64
|
||||
err := database.QueryRow(
|
||||
"SELECT notebook_id, expires_at FROM share_links WHERE token = ?", token,
|
||||
).Scan(¬ebookID, &expiresAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("link not found")
|
||||
}
|
||||
|
||||
if expiresAt != nil && time.Now().UnixMilli() > *expiresAt {
|
||||
return 0, fmt.Errorf("link expired")
|
||||
}
|
||||
|
||||
return notebookID, nil
|
||||
}
|
||||
|
||||
// RevokeLink deletes a share link.
|
||||
func RevokeLink(database *sql.DB, token string) error {
|
||||
_, err := database.Exec("DELETE FROM share_links WHERE token = ?", token)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListLinks returns all active share links for a notebook.
|
||||
func ListLinks(database *sql.DB, notebookID int64, baseURL string) ([]LinkInfo, error) {
|
||||
rows, err := database.Query(
|
||||
"SELECT token, created_at, expires_at FROM share_links WHERE notebook_id = ? ORDER BY created_at DESC",
|
||||
notebookID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var links []LinkInfo
|
||||
for rows.Next() {
|
||||
var token string
|
||||
var createdAt int64
|
||||
var expiresAt *int64
|
||||
if err := rows.Scan(&token, &createdAt, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
li := LinkInfo{
|
||||
Token: token,
|
||||
URL: baseURL + "/s/" + token,
|
||||
CreatedAt: time.UnixMilli(createdAt),
|
||||
}
|
||||
if expiresAt != nil {
|
||||
t := time.UnixMilli(*expiresAt)
|
||||
li.ExpiresAt = &t
|
||||
}
|
||||
links = append(links, li)
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
Reference in New Issue
Block a user