diff options
| author | Leonardo Bishop <me@leonardobishop.net> | 2025-09-17 18:13:30 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.net> | 2025-09-17 18:13:30 +0100 |
| commit | 39a926cd521806aedc298ddd671d1a118794fcec (patch) | |
| tree | a38908af8c91e01b2acec112b871c951e1bf6c1b | |
| parent | 1b7c07d9bbfb7984536a3aeade0f543251f1a666 (diff) | |
Add endpoints for web extension
| -rw-r--r-- | api/dto/entry.go | 14 | ||||
| -rw-r--r-- | api/handlers/entry.go | 105 | ||||
| -rw-r--r-- | api/handlers/record.go | 35 | ||||
| -rw-r--r-- | api/middleware/auth.go | 51 | ||||
| -rw-r--r-- | api/middleware/cors.go | 12 | ||||
| -rw-r--r-- | api/router.go | 9 | ||||
| -rw-r--r-- | pkg/database/migrations/0003_unique_url.sql | 3 | ||||
| -rw-r--r-- | pkg/database/migrations/0004_unread_kind.sql | 5 | ||||
| -rw-r--r-- | pkg/database/query/entries.sql | 20 | ||||
| -rw-r--r-- | pkg/database/sqlc/entries.sql.go | 99 | ||||
| -rw-r--r-- | pkg/entries/entries.go | 58 |
11 files changed, 369 insertions, 42 deletions
diff --git a/api/dto/entry.go b/api/dto/entry.go index 85e39f3..8346b18 100644 --- a/api/dto/entry.go +++ b/api/dto/entry.go @@ -5,5 +5,17 @@ type CreateEntryRequest struct { Kind string `json:"kind"` Url string `json:"url"` Description string `json:"description"` - Token string `json:"token"` +} + +type UpdateEntryRequest struct { + Id int64 `json:"id"` + Kind string `json:"kind"` +} + +type DeleteEntryRequest struct { + Id int64 `json:"id"` +} + +type GetEntryRequest struct { + Url string `json:"url"` } diff --git a/api/handlers/entry.go b/api/handlers/entry.go new file mode 100644 index 0000000..e94dfea --- /dev/null +++ b/api/handlers/entry.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "errors" + "net/http" + + "git.leonardobishop.net/stash/api/dto" + "git.leonardobishop.net/stash/pkg/entries" +) + +func GetEntryURLs(service entries.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + entries, err := service.GetEntryURLs() + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: entries, + } + }) +} + +func GetEntry(service entries.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.GetEntryRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + entry, err := service.GetEntryByUrl(request.Url) + if err != nil { + if errors.Is(err, entries.ErrEntryNotFound) { + return &dto.ErrorResponse{ + Code: http.StatusNotFound, + Message: "entry not found", + } + } + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: entry, + } + }) +} + +func RecordEntry(service entries.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.CreateEntryRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + entry, err := service.CreateEntry(request.Title, request.Kind, request.Url, request.Description) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusCreated, + Data: entry, + } + }) +} + +func UpdateEntry(service entries.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.UpdateEntryRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + entry, err := service.UpdateEntryKind(request.Id, request.Kind) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: entry, + } + }) +} + +func DeleteEntry(service entries.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.DeleteEntryRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + err := service.DeleteEntry(request.Id) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: nil, + } + }) +} diff --git a/api/handlers/record.go b/api/handlers/record.go deleted file mode 100644 index cccce6b..0000000 --- a/api/handlers/record.go +++ /dev/null @@ -1,35 +0,0 @@ -package handlers - -import ( - "crypto/subtle" - "net/http" - - "git.leonardobishop.net/stash/api/dto" - "git.leonardobishop.net/stash/pkg/entries" -) - -func RecordEntry(service entries.Service, token string) http.HandlerFunc { - return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { - var request dto.CreateEntryRequest - if err := dto.ReadDto(r, &request); err != nil { - return err - } - - if subtle.ConstantTimeCompare([]byte(token), []byte(request.Token)) != 1 { - return &dto.ErrorResponse{ - Code: http.StatusForbidden, - Message: "Forbidden", - } - } - - entry, err := service.CreateEntry(request.Title, request.Kind, request.Url, request.Description) - if err != nil { - return err - } - - return &dto.OkResponse{ - Code: http.StatusCreated, - Data: entry, - } - }) -} diff --git a/api/middleware/auth.go b/api/middleware/auth.go new file mode 100644 index 0000000..f6e5c4b --- /dev/null +++ b/api/middleware/auth.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "crypto/subtle" + "fmt" + "net/http" + "strings" + + "git.leonardobishop.net/stash/api/dto" +) + +func MustAuthenticate(token string) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + givenToken, err := extractBearerToken(authHeader) + if err != nil { + dto.WriteDto(w, r, &dto.ErrorResponse{ + Code: http.StatusUnauthorized, + Message: "Unauthorized", + }) + return + } + + if subtle.ConstantTimeCompare([]byte(token), []byte(givenToken)) != 1 { + dto.WriteDto(w, r, &dto.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Forbidden", + }) + return + } + + next(w, r) + } + } +} + +func extractBearerToken(header string) (string, error) { + const prefix = "Bearer " + if header == "" { + return "", fmt.Errorf("authorization header missing") + } + if !strings.HasPrefix(header, prefix) { + return "", fmt.Errorf("invalid authorization scheme") + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return "", fmt.Errorf("token is empty") + } + return token, nil +} diff --git a/api/middleware/cors.go b/api/middleware/cors.go index 926c1ed..1fbd850 100644 --- a/api/middleware/cors.go +++ b/api/middleware/cors.go @@ -2,11 +2,13 @@ package middleware import "net/http" -func Cors(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") +func PermissiveCors() func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - next(w, r) + next(w, r) + } } } diff --git a/api/router.go b/api/router.go index 076beec..89abbc0 100644 --- a/api/router.go +++ b/api/router.go @@ -20,7 +20,14 @@ type ApiServices struct { func NewServer(api ApiServices) *http.ServeMux { mux := http.NewServeMux() - mux.HandleFunc("POST /record", middleware.Cors(handlers.RecordEntry(api.EntiresService, api.Config.Token))) + cors := middleware.PermissiveCors() + auth := middleware.MustAuthenticate(api.Config.Token) + + mux.HandleFunc("POST /record", cors(auth(handlers.RecordEntry(api.EntiresService)))) + mux.HandleFunc("PUT /record", cors(auth(handlers.UpdateEntry(api.EntiresService)))) + mux.HandleFunc("DELETE /record", cors(auth(handlers.DeleteEntry(api.EntiresService)))) + mux.HandleFunc("GET /entry", cors(auth(handlers.GetEntryURLs(api.EntiresService)))) + mux.HandleFunc("POST /entry", cors(auth(handlers.GetEntry(api.EntiresService)))) mux.HandleFunc("GET /html", handlers.GetEntriesHtml(api.EntiresService, api.HtmlService)) mux.HandleFunc("GET /", handlers.Pong()) diff --git a/pkg/database/migrations/0003_unique_url.sql b/pkg/database/migrations/0003_unique_url.sql new file mode 100644 index 0000000..8be3293 --- /dev/null +++ b/pkg/database/migrations/0003_unique_url.sql @@ -0,0 +1,3 @@ +-- +goose Up + +CREATE UNIQUE INDEX ux_entries_url ON entries(url); diff --git a/pkg/database/migrations/0004_unread_kind.sql b/pkg/database/migrations/0004_unread_kind.sql new file mode 100644 index 0000000..e583050 --- /dev/null +++ b/pkg/database/migrations/0004_unread_kind.sql @@ -0,0 +1,5 @@ +-- +goose Up + +INSERT INTO kinds (name, emoji) +VALUES + ("unread", "📚"); diff --git a/pkg/database/query/entries.sql b/pkg/database/query/entries.sql index 1f39016..9584adf 100644 --- a/pkg/database/query/entries.sql +++ b/pkg/database/query/entries.sql @@ -5,7 +5,27 @@ FROM kinds WHERE kinds.name = ? RETURNING *; +-- name: UpdateEntryKind :one +UPDATE entries +SET kind = (SELECT id FROM kinds WHERE kinds.name = ?) +WHERE entries.id = ? +RETURNING *; + +-- name: DeleteEntry :execrows +DELETE FROM entries +WHERE id = ?; + -- name: GetEntries :many SELECT title, url, description, timestamp, kinds.name as kind_name, kinds.emoji as kind_emoji FROM entries JOIN kinds ON entries.kind == kinds.id ORDER BY timestamp DESC; + +-- name: GetEntryURLs :many +SELECT url FROM entries +ORDER BY timestamp DESC; + +-- name: GetEntryByUrl :one +SELECT entries.id, title, url, description, timestamp, kinds.name as kind_name, kinds.emoji as kind_emoji FROM entries +JOIN kinds ON entries.kind == kinds.id +WHERE url = ? +LIMIT 1; diff --git a/pkg/database/sqlc/entries.sql.go b/pkg/database/sqlc/entries.sql.go index c7b5e1c..846076d 100644 --- a/pkg/database/sqlc/entries.sql.go +++ b/pkg/database/sqlc/entries.sql.go @@ -43,6 +43,19 @@ func (q *Queries) CreateEntryWithKindName(ctx context.Context, arg CreateEntryWi return i, err } +const deleteEntry = `-- name: DeleteEntry :execrows +DELETE FROM entries +WHERE id = ? +` + +func (q *Queries) DeleteEntry(ctx context.Context, id int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteEntry, id) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const getEntries = `-- name: GetEntries :many SELECT title, url, description, timestamp, kinds.name as kind_name, kinds.emoji as kind_emoji FROM entries JOIN kinds ON entries.kind == kinds.id @@ -87,3 +100,89 @@ func (q *Queries) GetEntries(ctx context.Context) ([]GetEntriesRow, error) { } return items, nil } + +const getEntryByUrl = `-- name: GetEntryByUrl :one +SELECT entries.id, title, url, description, timestamp, kinds.name as kind_name, kinds.emoji as kind_emoji FROM entries +JOIN kinds ON entries.kind == kinds.id +WHERE url = ? +LIMIT 1 +` + +type GetEntryByUrlRow struct { + ID int64 `json:"id"` + Title string `json:"title"` + Url string `json:"url"` + Description string `json:"description"` + Timestamp string `json:"timestamp"` + KindName string `json:"kind_name"` + KindEmoji string `json:"kind_emoji"` +} + +func (q *Queries) GetEntryByUrl(ctx context.Context, url string) (GetEntryByUrlRow, error) { + row := q.db.QueryRowContext(ctx, getEntryByUrl, url) + var i GetEntryByUrlRow + err := row.Scan( + &i.ID, + &i.Title, + &i.Url, + &i.Description, + &i.Timestamp, + &i.KindName, + &i.KindEmoji, + ) + return i, err +} + +const getEntryURLs = `-- name: GetEntryURLs :many +SELECT url FROM entries +ORDER BY timestamp DESC +` + +func (q *Queries) GetEntryURLs(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getEntryURLs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var url string + if err := rows.Scan(&url); err != nil { + return nil, err + } + items = append(items, url) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateEntryKind = `-- name: UpdateEntryKind :one +UPDATE entries +SET kind = (SELECT id FROM kinds WHERE kinds.name = ?) +WHERE entries.id = ? +RETURNING id, title, kind, url, description, timestamp +` + +type UpdateEntryKindParams struct { + Name string `json:"name"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateEntryKind(ctx context.Context, arg UpdateEntryKindParams) (Entry, error) { + row := q.db.QueryRowContext(ctx, updateEntryKind, arg.Name, arg.ID) + var i Entry + err := row.Scan( + &i.ID, + &i.Title, + &i.Kind, + &i.Url, + &i.Description, + &i.Timestamp, + ) + return i, err +} diff --git a/pkg/entries/entries.go b/pkg/entries/entries.go index 0764f93..92f28d9 100644 --- a/pkg/entries/entries.go +++ b/pkg/entries/entries.go @@ -3,6 +3,7 @@ package entries import ( "context" "database/sql" + "errors" "fmt" "git.leonardobishop.net/stash/pkg/database/sqlc" @@ -10,9 +11,17 @@ import ( type Service interface { CreateEntry(title, kind, url, description string) (*sqlc.Entry, error) + UpdateEntryKind(id int64, kind string) (*sqlc.Entry, error) + DeleteEntry(id int64) error GetEntries() ([]sqlc.GetEntriesRow, error) + GetEntryURLs() ([]string, error) + GetEntryByUrl(url string) (*sqlc.GetEntryByUrlRow, error) } +var ( + ErrEntryNotFound = errors.New("entry not found") +) + type service struct { db *sql.DB } @@ -40,6 +49,30 @@ func (s *service) CreateEntry(title, kind, url, description string) (*sqlc.Entry return &entry, nil } +func (s *service) UpdateEntryKind(id int64, kind string) (*sqlc.Entry, error) { + queries := sqlc.New(s.db) + + entry, err := queries.UpdateEntryKind(context.Background(), sqlc.UpdateEntryKindParams{ + ID: id, + Name: kind, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrEntryNotFound + } + return nil, fmt.Errorf("could not update entry: %w", err) + } + + return &entry, nil +} + +func (s *service) DeleteEntry(id int64) error { + queries := sqlc.New(s.db) + + _, err := queries.DeleteEntry(context.Background(), id) + return err +} + func (s *service) GetEntries() ([]sqlc.GetEntriesRow, error) { queries := sqlc.New(s.db) @@ -50,3 +83,28 @@ func (s *service) GetEntries() ([]sqlc.GetEntriesRow, error) { return entries, nil } + +func (s *service) GetEntryURLs() ([]string, error) { + queries := sqlc.New(s.db) + + entries, err := queries.GetEntryURLs(context.Background()) + if err != nil { + return make([]string, 0), fmt.Errorf("could not get entries: %w", err) + } + + return entries, nil +} + +func (s *service) GetEntryByUrl(url string) (*sqlc.GetEntryByUrlRow, error) { + queries := sqlc.New(s.db) + + entry, err := queries.GetEntryByUrl(context.Background(), url) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrEntryNotFound + } + return nil, fmt.Errorf("could not get entry: %w", err) + } + + return &entry, nil +} |
