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 }