feat: add Stow prototype iOS app and Gin backend
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type Response struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func success(c *gin.Context, status int, data any) {
|
||||
c.JSON(status, Response{Code: "ok", Message: "success", Data: data})
|
||||
}
|
||||
|
||||
func failure(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, Response{Code: code, Message: message, Data: nil})
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"stow/backend/internal/store"
|
||||
)
|
||||
|
||||
type DataStore interface {
|
||||
CreateAnonymousSession(context.Context) (string, string, error)
|
||||
Authenticate(context.Context, string) (string, error)
|
||||
CreateItem(context.Context, string, store.NewItem) (store.Item, error)
|
||||
GetItem(context.Context, string, string) (store.Item, error)
|
||||
ListItems(context.Context, string, int) ([]store.Item, error)
|
||||
SearchItems(context.Context, string, string, int) ([]store.Item, error)
|
||||
UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error)
|
||||
DeleteItem(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type Server struct{ store DataStore }
|
||||
|
||||
func New(dataStore DataStore) http.Handler {
|
||||
server := &Server{store: dataStore}
|
||||
router := gin.New()
|
||||
router.HandleMethodNotAllowed = true
|
||||
router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
|
||||
}))
|
||||
_ = router.SetTrustedProxies(nil)
|
||||
router.NoRoute(func(c *gin.Context) { failure(c, http.StatusNotFound, "not_found", "接口不存在") })
|
||||
router.NoMethod(func(c *gin.Context) {
|
||||
failure(c, http.StatusMethodNotAllowed, "method_not_allowed", "请求方法不支持")
|
||||
})
|
||||
|
||||
v1 := router.Group("/v1")
|
||||
v1.GET("/health", func(c *gin.Context) { success(c, http.StatusOK, gin.H{"status": "ok"}) })
|
||||
v1.POST("/session/anonymous", server.anonymousSession)
|
||||
authorized := v1.Group("")
|
||||
authorized.Use(server.requireAuth())
|
||||
authorized.POST("/items", server.createItem)
|
||||
authorized.GET("/items", server.listItems)
|
||||
authorized.GET("/items/:id", server.getItem)
|
||||
authorized.PATCH("/items/:id", server.updateItem)
|
||||
authorized.DELETE("/items/:id", server.deleteItem)
|
||||
authorized.POST("/search", server.search)
|
||||
return router
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") || strings.TrimSpace(header[7:]) == "" {
|
||||
failure(c, http.StatusUnauthorized, "missing_token", "请先创建匿名会话")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
userID, err := s.store.Authenticate(c.Request.Context(), strings.TrimSpace(header[7:]))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
failure(c, http.StatusUnauthorized, "invalid_token", "会话无效或已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("userID", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) anonymousSession(c *gin.Context) {
|
||||
userID, token, err := s.store.CreateAnonymousSession(c.Request.Context())
|
||||
if err != nil {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "创建会话失败")
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, gin.H{"user_id": userID, "access_token": token, "token_type": "Bearer"})
|
||||
}
|
||||
|
||||
func (s *Server) createItem(c *gin.Context) {
|
||||
var input store.NewItem
|
||||
if !readJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
if message := validateNewItem(&input); message != "" {
|
||||
failure(c, http.StatusBadRequest, "invalid_item", message)
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateItem(c.Request.Context(), c.GetString("userID"), input)
|
||||
if err != nil {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "保存失败")
|
||||
return
|
||||
}
|
||||
success(c, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) listItems(c *gin.Context) {
|
||||
items, err := s.store.ListItems(c.Request.Context(), c.GetString("userID"), parseLimit(c.Query("limit")))
|
||||
if err != nil {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "读取失败")
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (s *Server) getItem(c *gin.Context) {
|
||||
item, err := s.store.GetItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))
|
||||
if !handleItemError(c, err) {
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) updateItem(c *gin.Context) {
|
||||
var patch store.ItemPatch
|
||||
if !readJSON(c, &patch) {
|
||||
return
|
||||
}
|
||||
if message := validatePatch(patch); message != "" {
|
||||
failure(c, http.StatusBadRequest, "invalid_item", message)
|
||||
return
|
||||
}
|
||||
item, err := s.store.UpdateItem(c.Request.Context(), c.GetString("userID"), c.Param("id"), patch)
|
||||
if !handleItemError(c, err) {
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (s *Server) deleteItem(c *gin.Context) {
|
||||
if !handleItemError(c, s.store.DeleteItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))) {
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (s *Server) search(c *gin.Context) {
|
||||
var request struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if !readJSON(c, &request) {
|
||||
return
|
||||
}
|
||||
request.Query = strings.TrimSpace(request.Query)
|
||||
if request.Query == "" || len([]rune(request.Query)) > 200 {
|
||||
failure(c, http.StatusBadRequest, "invalid_query", "搜索内容需为 1 到 200 字")
|
||||
return
|
||||
}
|
||||
if request.Limit <= 0 {
|
||||
request.Limit = 20
|
||||
}
|
||||
if request.Limit > 100 {
|
||||
request.Limit = 100
|
||||
}
|
||||
items, err := s.store.SearchItems(c.Request.Context(), c.GetString("userID"), request.Query, request.Limit)
|
||||
if err != nil {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "搜索失败")
|
||||
return
|
||||
}
|
||||
success(c, http.StatusOK, gin.H{"items": items, "query": request.Query})
|
||||
}
|
||||
|
||||
func handleItemError(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
failure(c, http.StatusNotFound, "not_found", "收藏不存在")
|
||||
} else if errors.Is(err, store.ErrConflict) {
|
||||
failure(c, http.StatusConflict, "conflict", "收藏已更新,请重试")
|
||||
} else {
|
||||
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readJSON(c *gin.Context, destination any) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
failure(c, http.StatusBadRequest, "invalid_json", "请求内容不是有效 JSON")
|
||||
return false
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
failure(c, http.StatusBadRequest, "invalid_json", "请求只能包含一个 JSON 对象")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseLimit(raw string) int {
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit <= 0 {
|
||||
return 20
|
||||
}
|
||||
if limit > 100 {
|
||||
return 100
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func validateNewItem(input *store.NewItem) string {
|
||||
input.Title = strings.TrimSpace(input.Title)
|
||||
input.Summary = strings.TrimSpace(input.Summary)
|
||||
if input.Title == "" || len([]rune(input.Title)) > 160 {
|
||||
return "标题需为 1 到 160 字"
|
||||
}
|
||||
if len([]rune(input.Summary)) > 2000 {
|
||||
return "摘要不能超过 2000 字"
|
||||
}
|
||||
if input.PrimaryType == "" {
|
||||
input.PrimaryType = "other"
|
||||
}
|
||||
if !validType(input.PrimaryType) {
|
||||
return "无效的分类"
|
||||
}
|
||||
if !validSource(input.SourceType) {
|
||||
return "无效的来源类型"
|
||||
}
|
||||
if input.SourceURL != nil {
|
||||
parsed, err := url.Parse(*input.SourceURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return "无效的来源链接"
|
||||
}
|
||||
}
|
||||
if len(input.Tags) > 20 {
|
||||
return "标签不能超过 20 个"
|
||||
}
|
||||
for _, tag := range input.Tags {
|
||||
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
|
||||
return "标签需为 1 到 40 字"
|
||||
}
|
||||
}
|
||||
if input.CapturedAt != nil && input.CapturedAt.After(time.Now().Add(24*time.Hour)) {
|
||||
return "收藏时间不能晚于明天"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validatePatch(patch store.ItemPatch) string {
|
||||
if patch.Title != nil && (strings.TrimSpace(*patch.Title) == "" || len([]rune(*patch.Title)) > 160) {
|
||||
return "标题需为 1 到 160 字"
|
||||
}
|
||||
if patch.Summary != nil && len([]rune(*patch.Summary)) > 2000 {
|
||||
return "摘要不能超过 2000 字"
|
||||
}
|
||||
if patch.PrimaryType != nil && !validType(*patch.PrimaryType) {
|
||||
return "无效的分类"
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
if len(*patch.Tags) > 20 {
|
||||
return "标签不能超过 20 个"
|
||||
}
|
||||
for _, tag := range *patch.Tags {
|
||||
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
|
||||
return "标签需为 1 到 40 字"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validType(value string) bool {
|
||||
switch value {
|
||||
case "place", "product", "content", "event", "task", "document", "conversation", "other":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validSource(value string) bool {
|
||||
switch value {
|
||||
case "screenshot", "manual_photo", "share_image", "share_url", "share_text", "manual_url", "manual_text":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"stow/backend/internal/store"
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
created bool
|
||||
lastUser string
|
||||
lastItem store.NewItem
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateAnonymousSession(context.Context) (string, string, error) {
|
||||
return "user-1", "token-1", nil
|
||||
}
|
||||
func (f *fakeStore) Authenticate(_ context.Context, token string) (string, error) {
|
||||
if token != "token-1" {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
return "user-1", nil
|
||||
}
|
||||
func (f *fakeStore) CreateItem(_ context.Context, userID string, item store.NewItem) (store.Item, error) {
|
||||
f.created, f.lastUser, f.lastItem = true, userID, item
|
||||
return store.Item{ID: "item-1", SourceType: item.SourceType, PrimaryType: item.PrimaryType, Title: item.Title, Summary: item.Summary, Status: "ready", Tags: item.Tags, CreatedAt: time.Now()}, nil
|
||||
}
|
||||
func (f *fakeStore) GetItem(context.Context, string, string) (store.Item, error) {
|
||||
return store.Item{}, store.ErrNotFound
|
||||
}
|
||||
func (f *fakeStore) ListItems(context.Context, string, int) ([]store.Item, error) {
|
||||
return []store.Item{}, nil
|
||||
}
|
||||
func (f *fakeStore) SearchItems(context.Context, string, string, int) ([]store.Item, error) {
|
||||
return []store.Item{}, nil
|
||||
}
|
||||
func (f *fakeStore) UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error) {
|
||||
return store.Item{}, store.ErrNotFound
|
||||
}
|
||||
func (f *fakeStore) DeleteItem(context.Context, string, string) error { return store.ErrNotFound }
|
||||
|
||||
func TestItemsRequireSession(t *testing.T) {
|
||||
fake := &fakeStore{}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(`{"source_type":"manual_text","title":"咖啡店"}`))
|
||||
response := httptest.NewRecorder()
|
||||
New(fake).ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", response.Code)
|
||||
}
|
||||
if fake.created {
|
||||
t.Fatal("item was created without authentication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateItemValidatesAndUsesAuthenticatedUser(t *testing.T) {
|
||||
fake := &fakeStore{}
|
||||
handler := New(fake)
|
||||
for _, test := range []struct {
|
||||
name, body string
|
||||
want int
|
||||
}{
|
||||
{"invalid source", `{"source_type":"unknown","title":"咖啡店"}`, http.StatusBadRequest},
|
||||
{"valid item", `{"source_type":"manual_text","title":"咖啡店","tags":["上海"]}`, http.StatusCreated},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewBufferString(test.body))
|
||||
request.Header.Set("Authorization", "Bearer token-1")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != test.want {
|
||||
t.Fatalf("status = %d, want %d: %s", response.Code, test.want, response.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
if !fake.created || fake.lastUser != "user-1" || fake.lastItem.Title != "咖啡店" {
|
||||
t.Fatalf("wrong authenticated create: %+v", fake)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousSessionReturnsToken(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
New(&fakeStore{}).ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/v1/session/anonymous", nil))
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Code != "ok" || body.Data["access_token"] != "token-1" || body.Data["token_type"] != "Bearer" {
|
||||
t.Fatalf("unexpected response: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllResponsesUseEnvelope(t *testing.T) {
|
||||
handler := New(&fakeStore{})
|
||||
for _, test := range []struct {
|
||||
method string
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{http.MethodGet, "/v1/health", http.StatusOK},
|
||||
{http.MethodGet, "/v1/items", http.StatusUnauthorized},
|
||||
{http.MethodGet, "/missing", http.StatusNotFound},
|
||||
{http.MethodPut, "/v1/health", http.StatusMethodNotAllowed},
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
|
||||
if response.Code != test.want {
|
||||
t.Fatalf("%s %s: status %d, want %d", test.method, test.path, response.Code, test.want)
|
||||
}
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("%s %s: %v", test.method, test.path, err)
|
||||
}
|
||||
if body.Code == "" || body.Message == "" || body.Data == nil {
|
||||
t.Fatalf("%s %s: invalid envelope: %s", test.method, test.path, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("item not found")
|
||||
var ErrConflict = errors.New("item changed during update")
|
||||
|
||||
type Store struct{ db *sql.DB }
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(12)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceURL *string `json:"source_url,omitempty"`
|
||||
PrimaryType string `json:"primary_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
CapturedAt *time.Time `json:"captured_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Version int64 `json:"version"`
|
||||
}
|
||||
|
||||
type NewItem struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceURL *string `json:"source_url"`
|
||||
PrimaryType string `json:"primary_type"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
CapturedAt *time.Time `json:"captured_at"`
|
||||
}
|
||||
|
||||
type ItemPatch struct {
|
||||
Title *string `json:"title"`
|
||||
Summary *string `json:"summary"`
|
||||
PrimaryType *string `json:"primary_type"`
|
||||
Tags *[]string `json:"tags"`
|
||||
}
|
||||
|
||||
func NewUUID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
s := hex.EncodeToString(b[:])
|
||||
return fmt.Sprintf("%s-%s-%s-%s-%s", s[:8], s[8:12], s[12:16], s[16:20], s[20:]), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAnonymousSession(ctx context.Context) (userID, token string, err error) {
|
||||
userID, err = NewUUID()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
sessionID, err := NewUUID()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var secret [32]byte
|
||||
if _, err = rand.Read(secret[:]); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
token = base64.RawURLEncoding.EncodeToString(secret[:])
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO stow_users (id) VALUES ($1)`, userID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO stow_sessions (id, user_id, token_hash, expires_at) VALUES ($1,$2,$3,$4)`, sessionID, userID, hash[:], time.Now().Add(365*24*time.Hour)); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return userID, token, nil
|
||||
}
|
||||
|
||||
func (s *Store) Authenticate(ctx context.Context, token string) (string, error) {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
var userID string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT user_id FROM stow_sessions WHERE token_hash=$1 AND expires_at>now()`, hash[:]).Scan(&userID)
|
||||
return userID, err
|
||||
}
|
||||
|
||||
const itemColumns = `id, source_type, source_url, primary_type, title, summary, status, tags, captured_at, created_at, updated_at, version`
|
||||
|
||||
func scanItem(scanner interface{ Scan(...any) error }) (Item, error) {
|
||||
var item Item
|
||||
var sourceURL sql.NullString
|
||||
var capturedAt sql.NullTime
|
||||
var tagsJSON []byte
|
||||
err := scanner.Scan(&item.ID, &item.SourceType, &sourceURL, &item.PrimaryType, &item.Title, &item.Summary, &item.Status, &tagsJSON, &capturedAt, &item.CreatedAt, &item.UpdatedAt, &item.Version)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if sourceURL.Valid {
|
||||
item.SourceURL = &sourceURL.String
|
||||
}
|
||||
if capturedAt.Valid {
|
||||
item.CapturedAt = &capturedAt.Time
|
||||
}
|
||||
if err := json.Unmarshal(tagsJSON, &item.Tags); err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if item.Tags == nil {
|
||||
item.Tags = []string{}
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func searchText(item NewItem) string {
|
||||
return strings.Join(append([]string{item.Title, item.Summary}, item.Tags...), " ")
|
||||
}
|
||||
|
||||
func (s *Store) CreateItem(ctx context.Context, userID string, input NewItem) (Item, error) {
|
||||
id, err := NewUUID()
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
tags, err := json.Marshal(input.Tags)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
return scanItem(s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO stow_items (id,user_id,source_type,source_url,primary_type,title,summary,status,tags,search_text,captured_at,ready_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,'ready',$8,$9,$10,now())
|
||||
RETURNING `+itemColumns,
|
||||
id, userID, input.SourceType, input.SourceURL, input.PrimaryType, input.Title, input.Summary, tags, searchText(input), input.CapturedAt))
|
||||
}
|
||||
|
||||
func (s *Store) GetItem(ctx context.Context, userID, itemID string) (Item, error) {
|
||||
item, err := scanItem(s.db.QueryRowContext(ctx, `SELECT `+itemColumns+` FROM stow_items WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, itemID, userID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) ListItems(ctx context.Context, userID string, limit int) ([]Item, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM stow_items WHERE user_id=$1 AND deleted_at IS NULL ORDER BY created_at DESC,id DESC LIMIT $2`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Item, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) SearchItems(ctx context.Context, userID, query string, limit int) ([]Item, error) {
|
||||
escaped := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(query)
|
||||
pattern := "%" + escaped + "%"
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+itemColumns+` FROM stow_items
|
||||
WHERE user_id=$1 AND deleted_at IS NULL AND (search_text ILIKE $2 ESCAPE '\' OR title ILIKE $2 ESCAPE '\')
|
||||
ORDER BY GREATEST(similarity(title,$3),similarity(search_text,$3)) DESC,created_at DESC LIMIT $4`, userID, pattern, query, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Item, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateItem(ctx context.Context, userID, itemID string, patch ItemPatch) (Item, error) {
|
||||
current, err := s.GetItem(ctx, userID, itemID)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
if patch.Title != nil {
|
||||
current.Title = *patch.Title
|
||||
}
|
||||
if patch.Summary != nil {
|
||||
current.Summary = *patch.Summary
|
||||
}
|
||||
if patch.PrimaryType != nil {
|
||||
current.PrimaryType = *patch.PrimaryType
|
||||
}
|
||||
if patch.Tags != nil {
|
||||
current.Tags = *patch.Tags
|
||||
}
|
||||
tags, err := json.Marshal(current.Tags)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
newText := searchText(NewItem{Title: current.Title, Summary: current.Summary, Tags: current.Tags})
|
||||
updated, err := scanItem(s.db.QueryRowContext(ctx, `UPDATE stow_items SET title=$3,summary=$4,primary_type=$5,tags=$6,search_text=$7,updated_at=now(),version=version+1
|
||||
WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL AND version=$8 RETURNING `+itemColumns,
|
||||
itemID, userID, current.Title, current.Summary, current.PrimaryType, tags, newText, current.Version))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, ErrConflict
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteItem(ctx context.Context, userID, itemID string) error {
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE stow_items SET deleted_at=now(),updated_at=now(),version=version+1 WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, itemID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user