summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/dto/entry.go14
-rw-r--r--api/handlers/entry.go105
-rw-r--r--api/handlers/record.go35
-rw-r--r--api/middleware/auth.go51
-rw-r--r--api/middleware/cors.go12
-rw-r--r--api/router.go9
-rw-r--r--pkg/database/migrations/0003_unique_url.sql3
-rw-r--r--pkg/database/migrations/0004_unread_kind.sql5
-rw-r--r--pkg/database/query/entries.sql20
-rw-r--r--pkg/database/sqlc/entries.sql.go99
-rw-r--r--pkg/entries/entries.go58
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
+}