Compare commits
8
Commits
2525e8b68d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319d952855 | ||
|
|
968d2920c8 | ||
|
|
bf0f1af649 | ||
|
|
5e4120a027 | ||
|
|
e14aa0fea6 | ||
|
|
1b381d9385 | ||
|
|
b6f2ac06ae | ||
|
|
649c9687ac |
+2
-2
@@ -1,9 +1,9 @@
|
||||
## Taiga
|
||||
- TAIGA_URL: https://taiga.db-extern.de
|
||||
- TAIGA_PROJECT_SLUG: obstsortendatenbank
|
||||
- TAIGA_PROJECT_SLUG: obstsortendatenbank-1
|
||||
|
||||
## Stories
|
||||
Before implementing a story: read its spec file in docs/planning/specs/ first.
|
||||
Before starting a story: read its spec file in docs/planning/specs/ first.
|
||||
|
||||
## Branching
|
||||
Stories developed sequentially; prior story branch may not be merged to main yet.
|
||||
|
||||
@@ -11,6 +11,7 @@ frontend/.vite/
|
||||
# Environment — never commit real credentials
|
||||
.env
|
||||
backend/.env
|
||||
users.env
|
||||
|
||||
# Import data — large binary files, not committed
|
||||
03-data/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# -include: silently skip if .env doesn't exist (e.g. fresh clone before 'cp .env.example .env')
|
||||
-include .env
|
||||
-include backend/.env
|
||||
export
|
||||
|
||||
.PHONY: dev-db migrate-up migrate-down run-backend run-frontend test fmt
|
||||
|
||||
@@ -9,17 +9,51 @@ A full-stack fruit-variety database (Go + Vue 3).
|
||||
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
|
||||
- Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list.
|
||||
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
|
||||
- Maintainers can log in with a username and password to access data-modifying operations; all write API endpoints and admin controls are protected behind JWT authentication.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env # set credentials
|
||||
cp backend/.env.example backend/.env # set JWT_SECRET and USERS_FILE
|
||||
cp users.env.example users.env # create user file (see below)
|
||||
./scripts/create_user.sh admin secret >> users.env # add a user (restart backend to reload)
|
||||
make dev-db # start Postgres (waits until healthy)
|
||||
make migrate-up # apply schema migrations
|
||||
make run-backend # Echo API on :3000
|
||||
make run-frontend # Vite dev server on :5000
|
||||
```
|
||||
|
||||
## Importing Legacy Data
|
||||
|
||||
Prerequisites: Postgres running and migrated, `psycopg2` installed (`pip install psycopg2-binary`), `03-data/` present at repo root.
|
||||
|
||||
Scripts must be run in order — publications depend on fruits being present.
|
||||
|
||||
### 1. Import fruits
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb?sslmode=disable python3 scripts/import_fruits.py
|
||||
```
|
||||
|
||||
Imports fruits, synonyms, and fruit images from `03-data/obstsorten.xml`. Idempotent: upserts by `osdb_number`.
|
||||
|
||||
### 2. Import publications
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb?sslmode=disable python3 scripts/import_publications.py
|
||||
```
|
||||
|
||||
Imports publications from `03-data/osws.xml`. For each publication: upserts the row, loads cover image, links fruits, and imports PDFs and fruit images. Idempotent: clears and re-inserts linked data on re-run.
|
||||
|
||||
## Managing Users
|
||||
|
||||
Add a user and restart the backend to reload:
|
||||
|
||||
```bash
|
||||
./scripts/create_user.sh <username> <password> >> users.env
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb?sslmode=disable
|
||||
PORT=3000
|
||||
JWT_SECRET=change-me-use-a-long-random-string
|
||||
USERS_FILE=../users.env
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"osdb/internal/auth"
|
||||
"osdb/internal/config"
|
||||
"osdb/internal/database"
|
||||
"osdb/internal/migrate"
|
||||
@@ -52,7 +53,13 @@ func serve(cfg *config.Config) {
|
||||
defer pool.Close()
|
||||
log.Println("database: connected")
|
||||
|
||||
e := New(pool)
|
||||
users, err := auth.LoadUsers(cfg.UsersFile)
|
||||
if err != nil {
|
||||
log.Fatalf("auth: load users from %s: %v", cfg.UsersFile, err)
|
||||
}
|
||||
log.Printf("auth: loaded %d user(s)", len(users))
|
||||
|
||||
e := New(pool, users, cfg.JWTSecret)
|
||||
|
||||
// Start server; signal failures via a channel so the main goroutine can
|
||||
// handle them without calling os.Exit from a goroutine (which would skip
|
||||
|
||||
@@ -6,17 +6,20 @@ import (
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"osdb/internal/handler"
|
||||
mw "osdb/internal/middleware"
|
||||
"osdb/internal/repository"
|
||||
)
|
||||
|
||||
// New creates and configures the Echo instance with all routes registered.
|
||||
func New(pool *pgxpool.Pool) *echo.Echo {
|
||||
func New(pool *pgxpool.Pool, users map[string]string, jwtSecret string) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
jwtAuth := mw.JWTAuth(jwtSecret)
|
||||
|
||||
health := handler.NewHealthHandler()
|
||||
|
||||
// Root health — used by docker healthcheck + ops tooling
|
||||
@@ -26,20 +29,25 @@ func New(pool *pgxpool.Pool) *echo.Echo {
|
||||
api := e.Group("/api/v1")
|
||||
api.GET("/health", health.Health)
|
||||
|
||||
// Auth (story #08)
|
||||
authHandler := handler.NewAuthHandler(users, jwtSecret)
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
|
||||
// Fruits (story #02)
|
||||
fruitRepo := repository.NewFruitRepo(pool)
|
||||
fruits := handler.NewFruitHandler(fruitRepo)
|
||||
|
||||
g := api.Group("/fruits")
|
||||
g.GET("", fruits.List)
|
||||
g.POST("", fruits.Create)
|
||||
g.POST("", fruits.Create, jwtAuth)
|
||||
g.GET("/:id", fruits.Get)
|
||||
g.PUT("/:id", fruits.Update)
|
||||
g.DELETE("/:id", fruits.Delete)
|
||||
g.PUT("/:id", fruits.Update, jwtAuth)
|
||||
g.DELETE("/:id", fruits.Delete, jwtAuth)
|
||||
g.GET("/:id/images", fruits.ListImages)
|
||||
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
|
||||
g.POST("/:id/images", fruits.UploadImage, jwtAuth, middleware.BodyLimit("5M"))
|
||||
g.GET("/:id/images/:imageId", fruits.ServeImage)
|
||||
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
|
||||
g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail)
|
||||
g.DELETE("/:id/images/:imageId", fruits.DeleteImage, jwtAuth)
|
||||
|
||||
// Publications (story #04)
|
||||
pubRepo := repository.NewPublicationRepo(pool)
|
||||
@@ -51,24 +59,30 @@ func New(pool *pgxpool.Pool) *echo.Echo {
|
||||
|
||||
p := api.Group("/publications")
|
||||
p.GET("", pubs.List)
|
||||
p.POST("", pubs.Create)
|
||||
p.POST("", pubs.Create, jwtAuth)
|
||||
p.GET("/:id", pubs.Get)
|
||||
p.PUT("/:id", pubs.Update)
|
||||
p.DELETE("/:id", pubs.Delete)
|
||||
p.POST("/:id/image", pubs.UploadCoverImage, middleware.BodyLimit("5M"))
|
||||
p.PUT("/:id", pubs.Update, jwtAuth)
|
||||
p.DELETE("/:id", pubs.Delete, jwtAuth)
|
||||
p.POST("/:id/image", pubs.UploadCoverImage, jwtAuth, middleware.BodyLimit("5M"))
|
||||
p.GET("/:id/image", pubs.ServeCoverImage)
|
||||
p.DELETE("/:id/image", pubs.DeleteCoverImage)
|
||||
p.DELETE("/:id/image", pubs.DeleteCoverImage, jwtAuth)
|
||||
p.GET("/:id/fruits", pubs.ListFruits)
|
||||
p.POST("/:id/fruits", pubs.LinkFruit)
|
||||
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit)
|
||||
p.POST("/:id/fruits", pubs.LinkFruit, jwtAuth)
|
||||
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit, jwtAuth)
|
||||
p.GET("/:id/descriptions", pubs.ListDescriptions)
|
||||
p.POST("/:id/descriptions", pubs.UploadDescription, middleware.BodyLimit("5M"))
|
||||
p.POST("/:id/descriptions", pubs.UploadDescription, jwtAuth, middleware.BodyLimit("5M"))
|
||||
p.GET("/:id/descriptions/:descId", pubs.ServeDescription)
|
||||
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription)
|
||||
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription, jwtAuth)
|
||||
p.GET("/:id/fruit-images", pubs.ListFruitImages)
|
||||
p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M"))
|
||||
p.POST("/:id/fruit-images", pubs.UploadFruitImage, jwtAuth, middleware.BodyLimit("5M"))
|
||||
p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage)
|
||||
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage)
|
||||
p.GET("/:id/fruit-images/:imgId/thumbnail", pubs.ServeFruitImageThumbnail)
|
||||
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage, jwtAuth)
|
||||
|
||||
// Admin (story #07) — all admin routes require auth
|
||||
admin := handler.NewAdminHandler(fruitRepo, pubRepo)
|
||||
adminGroup := api.Group("/admin", jwtAuth)
|
||||
adminGroup.POST("/backfill-thumbnails", admin.BackfillThumbnails)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
||||
@@ -27,6 +27,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Claims is the JWT payload.
|
||||
type Claims struct {
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// LoadUsers reads username:bcrypt_hash pairs from path.
|
||||
// Lines starting with '#' and blank lines are ignored.
|
||||
func LoadUsers(path string) (map[string]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
users := make(map[string]string)
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
users[parts[0]] = parts[1]
|
||||
}
|
||||
return users, scanner.Err()
|
||||
}
|
||||
|
||||
// VerifyPassword checks username+password against the bcrypt hash stored for that user.
|
||||
// The peppered input is "username:password".
|
||||
func VerifyPassword(users map[string]string, username, password string) bool {
|
||||
hash, ok := users[username]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(username+":"+password)) == nil
|
||||
}
|
||||
|
||||
// GenerateJWT creates a signed HS256 token for username, valid for 1 hour.
|
||||
func GenerateJWT(username, secret string) (string, error) {
|
||||
claims := Claims{
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ValidateJWT parses and verifies token, returning its claims.
|
||||
func ValidateJWT(tokenStr, secret string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"osdb/internal/auth"
|
||||
)
|
||||
|
||||
// hashPepper mirrors the pepper convention: bcrypt("username:password")
|
||||
func hashPepper(username, password string) string {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(h)
|
||||
}
|
||||
|
||||
func writeTempUsersFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "users*.env")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestLoadUsers_ParsesValidFile(t *testing.T) {
|
||||
hash := hashPepper("admin", "secret")
|
||||
path := writeTempUsersFile(t, "admin:"+hash+"\n")
|
||||
|
||||
users, err := auth.LoadUsers(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", len(users))
|
||||
}
|
||||
if _, ok := users["admin"]; !ok {
|
||||
t.Error("expected 'admin' in users map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsers_SkipsCommentLines(t *testing.T) {
|
||||
hash := hashPepper("admin", "secret")
|
||||
content := "# comment\nadmin:" + hash + "\n"
|
||||
path := writeTempUsersFile(t, content)
|
||||
|
||||
users, err := auth.LoadUsers(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("expected 1 user, got %d", len(users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUsers_MissingFile(t *testing.T) {
|
||||
_, err := auth.LoadUsers(filepath.Join(t.TempDir(), "nonexistent.env"))
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_CorrectPassword(t *testing.T) {
|
||||
hash := hashPepper("admin", "secret")
|
||||
users := map[string]string{"admin": hash}
|
||||
|
||||
if !auth.VerifyPassword(users, "admin", "secret") {
|
||||
t.Error("expected password to match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_WrongPassword(t *testing.T) {
|
||||
hash := hashPepper("admin", "secret")
|
||||
users := map[string]string{"admin": hash}
|
||||
|
||||
if auth.VerifyPassword(users, "admin", "wrong") {
|
||||
t.Error("expected password to not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_UnknownUser(t *testing.T) {
|
||||
if auth.VerifyPassword(map[string]string{}, "nobody", "secret") {
|
||||
t.Error("expected false for unknown user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAndValidateJWT(t *testing.T) {
|
||||
token, err := auth.GenerateJWT("admin", "testsecret")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateJWT: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
|
||||
claims, err := auth.ValidateJWT(token, "testsecret")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateJWT: %v", err)
|
||||
}
|
||||
if claims.Username != "admin" {
|
||||
t.Errorf("expected username 'admin', got %q", claims.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWT_WrongSecret(t *testing.T) {
|
||||
token, _ := auth.GenerateJWT("admin", "correct")
|
||||
if _, err := auth.ValidateJWT(token, "wrong"); err == nil {
|
||||
t.Error("expected error for wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJWT_MalformedToken(t *testing.T) {
|
||||
if _, err := auth.ValidateJWT("not.a.token", "secret"); err == nil {
|
||||
t.Error("expected error for malformed token")
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,15 @@ import (
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
Port string
|
||||
UsersFile string
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
// DATABASE_URL is required (no silent default — fails fast if missing).
|
||||
// PORT defaults to "3000".
|
||||
// USERS_FILE defaults to "users.env".
|
||||
// JWT_SECRET is required.
|
||||
func Load() *Config {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
@@ -21,13 +25,25 @@ func Load() *Config {
|
||||
"Copy .env.example to .env and set the correct value.")
|
||||
}
|
||||
|
||||
jwtSecret := os.Getenv("JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
log.Fatal("JWT_SECRET environment variable is required but not set.")
|
||||
}
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "3000"
|
||||
}
|
||||
|
||||
usersFile := os.Getenv("USERS_FILE")
|
||||
if usersFile == "" {
|
||||
usersFile = "users.env"
|
||||
}
|
||||
|
||||
return &Config{
|
||||
DatabaseURL: dbURL,
|
||||
Port: port,
|
||||
UsersFile: usersFile,
|
||||
JWTSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type Fruit struct {
|
||||
FruitType string `json:"fruit_type"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
Images []FruitImage `json:"images"`
|
||||
ThumbnailURL *string `json:"thumbnail_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type FruitBackfiller interface {
|
||||
BackfillThumbnails(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type PubBackfiller interface {
|
||||
BackfillThumbnails(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type AdminHandler struct {
|
||||
fruitRepo FruitBackfiller
|
||||
pubRepo PubBackfiller
|
||||
}
|
||||
|
||||
func NewAdminHandler(fruitRepo FruitBackfiller, pubRepo PubBackfiller) *AdminHandler {
|
||||
return &AdminHandler{fruitRepo: fruitRepo, pubRepo: pubRepo}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) BackfillThumbnails(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
fruitCount, err := h.fruitRepo.BackfillThumbnails(ctx)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "backfill failed: " + err.Error()})
|
||||
}
|
||||
|
||||
pubCount, err := h.pubRepo.BackfillThumbnails(ctx)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "backfill failed: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]int{
|
||||
"fruit_images_processed": fruitCount,
|
||||
"pub_fruit_images_processed": pubCount,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/auth"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication endpoints.
|
||||
type AuthHandler struct {
|
||||
users map[string]string
|
||||
jwtSecret string
|
||||
}
|
||||
|
||||
// NewAuthHandler creates an AuthHandler with the given user map and JWT secret.
|
||||
func NewAuthHandler(users map[string]string, jwtSecret string) *AuthHandler {
|
||||
return &AuthHandler{users: users, jwtSecret: jwtSecret}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// Login handles POST /api/v1/auth/login.
|
||||
func (h *AuthHandler) Login(c echo.Context) error {
|
||||
var req loginRequest
|
||||
if err := c.Bind(&req); err != nil || req.Username == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
if !auth.VerifyPassword(h.users, req.Username, req.Password) {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
}
|
||||
|
||||
token, err := auth.GenerateJWT(req.Username, h.jwtSecret)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "token generation failed"})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{"token": token})
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/handler"
|
||||
)
|
||||
|
||||
func makeUsersMap(username, password string) map[string]string {
|
||||
h, _ := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
|
||||
return map[string]string{username: string(h)}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_ValidCredentials(t *testing.T) {
|
||||
users := makeUsersMap("admin", "secret")
|
||||
h := handler.NewAuthHandler(users, "jwttest")
|
||||
|
||||
body := `{"username":"admin","password":"secret"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(req, rec)
|
||||
|
||||
if err := h.Login(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d — body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "token") {
|
||||
t.Errorf("expected 'token' in response, got: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_InvalidCredentials(t *testing.T) {
|
||||
users := makeUsersMap("admin", "secret")
|
||||
h := handler.NewAuthHandler(users, "jwttest")
|
||||
|
||||
body := `{"username":"admin","password":"wrong"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(req, rec)
|
||||
|
||||
if err := h.Login(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_MissingBody(t *testing.T) {
|
||||
users := makeUsersMap("admin", "secret")
|
||||
h := handler.NewAuthHandler(users, "jwttest")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
c := echo.New().NewContext(req, rec)
|
||||
|
||||
if err := h.Login(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ type FruitRepository interface {
|
||||
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error)
|
||||
AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error)
|
||||
GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error)
|
||||
GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error)
|
||||
DeleteImage(ctx context.Context, fruitID, imageID int) error
|
||||
}
|
||||
|
||||
@@ -291,3 +292,20 @@ func (h *FruitHandler) DeleteImage(c echo.Context) error {
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) ServeThumbnail(c echo.Context) error {
|
||||
fruitID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imageID, err := parseID(c, "imageId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.repo.GetThumbnailData(c.Request().Context(), fruitID, imageID)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
contentType := http.DetectContentType(data)
|
||||
return c.Blob(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
@@ -194,6 +194,14 @@ func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte
|
||||
return r.imageData[imageID], nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetThumbnailData(_ context.Context, fruitID, imageID int) ([]byte, error) {
|
||||
img, ok := r.images[imageID]
|
||||
if !ok || img.FruitID != fruitID {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
return r.imageData[imageID], nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
|
||||
img, ok := r.images[imageID]
|
||||
if !ok || img.FruitID != fruitID {
|
||||
|
||||
@@ -42,6 +42,7 @@ type PublicationRepository interface {
|
||||
ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error)
|
||||
AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error)
|
||||
GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error)
|
||||
GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error)
|
||||
DeleteFruitImage(ctx context.Context, pubID, imgID int) error
|
||||
|
||||
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error)
|
||||
@@ -386,6 +387,23 @@ func (h *PublicationHandler) ServeFruitImage(c echo.Context) error {
|
||||
return c.Blob(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ServeFruitImageThumbnail(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgID, err := parseID(c, "imgId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.repo.GetFruitImageThumbnailData(c.Request().Context(), pubID, imgID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
contentType := http.DetectContentType(data)
|
||||
return c.Blob(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) DeleteFruitImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -265,6 +265,14 @@ func (r *fakePubRepo) GetFruitImageData(_ context.Context, pubID, imgID int) ([]
|
||||
return r.fruitImgData[imgID], nil
|
||||
}
|
||||
|
||||
func (r *fakePubRepo) GetFruitImageThumbnailData(_ context.Context, pubID, imgID int) ([]byte, error) {
|
||||
img, ok := r.fruitImages[imgID]
|
||||
if !ok || img.PublicationID != pubID {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
return r.fruitImgData[imgID], nil
|
||||
}
|
||||
|
||||
func (r *fakePubRepo) DeleteFruitImage(_ context.Context, pubID, imgID int) error {
|
||||
img, ok := r.fruitImages[imgID]
|
||||
if !ok || img.PublicationID != pubID {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
const (
|
||||
bgThreshold = 10
|
||||
padding = 5
|
||||
maxDim = 300
|
||||
)
|
||||
|
||||
// CropToContent auto-crops src to the bounding box of non-background pixels,
|
||||
// adds padding, scales to fit within maxDim×maxDim, and re-encodes as JPEG.
|
||||
// Returns the original bytes unchanged if decoding fails.
|
||||
func CropToContent(src []byte) ([]byte, error) {
|
||||
img, _, err := image.Decode(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() == 0 || bounds.Dy() == 0 {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
bgColor := img.At(bounds.Min.X, bounds.Min.Y)
|
||||
|
||||
minX, minY, maxX, maxY := findContentBounds(img, bgColor, bounds)
|
||||
if minX > maxX || minY > maxY {
|
||||
return src, nil
|
||||
}
|
||||
|
||||
minX = max(bounds.Min.X, minX-padding)
|
||||
minY = max(bounds.Min.Y, minY-padding)
|
||||
maxX = min(bounds.Max.X-1, maxX+padding)
|
||||
maxY = min(bounds.Max.Y-1, maxY+padding)
|
||||
|
||||
cropRect := image.Rect(minX, minY, maxX+1, maxY+1)
|
||||
cropped := cropImage(img, cropRect)
|
||||
scaled := scaleDown(cropped)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 85}); err != nil {
|
||||
return src, nil
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func findContentBounds(img image.Image, bg color.Color, bounds image.Rectangle) (minX, minY, maxX, maxY int) {
|
||||
minX = bounds.Max.X
|
||||
minY = bounds.Max.Y
|
||||
maxX = bounds.Min.X - 1
|
||||
maxY = bounds.Min.Y - 1
|
||||
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
if channelDiff(img.At(x, y), bg) > bgThreshold {
|
||||
if x < minX {
|
||||
minX = x
|
||||
}
|
||||
if y < minY {
|
||||
minY = y
|
||||
}
|
||||
if x > maxX {
|
||||
maxX = x
|
||||
}
|
||||
if y > maxY {
|
||||
maxY = y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func channelDiff(c1, c2 color.Color) int {
|
||||
r1, g1, b1, _ := c1.RGBA()
|
||||
r2, g2, b2, _ := c2.RGBA()
|
||||
dr := abs(int(r1>>8) - int(r2>>8))
|
||||
dg := abs(int(g1>>8) - int(g2>>8))
|
||||
db := abs(int(b1>>8) - int(b2>>8))
|
||||
return max(dr, max(dg, db))
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
type subImager interface {
|
||||
SubImage(r image.Rectangle) image.Image
|
||||
}
|
||||
|
||||
func cropImage(img image.Image, rect image.Rectangle) image.Image {
|
||||
if si, ok := img.(subImager); ok {
|
||||
return si.SubImage(rect)
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy()))
|
||||
draw.Draw(dst, dst.Bounds(), img, rect.Min, draw.Src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func scaleDown(img image.Image) image.Image {
|
||||
bounds := img.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return img
|
||||
}
|
||||
|
||||
scaleW := float64(maxDim) / float64(w)
|
||||
scaleH := float64(maxDim) / float64(h)
|
||||
scale := scaleW
|
||||
if scaleH < scale {
|
||||
scale = scaleH
|
||||
}
|
||||
|
||||
newW := max(1, int(float64(w)*scale))
|
||||
newH := max(1, int(float64(h)*scale))
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||
for y := 0; y < newH; y++ {
|
||||
for x := 0; x < newW; x++ {
|
||||
srcX := bounds.Min.X + int(float64(x)/scale)
|
||||
srcY := bounds.Min.Y + int(float64(y)/scale)
|
||||
dst.Set(x, y, img.At(srcX, srcY))
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package imaging_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"osdb/internal/imaging"
|
||||
)
|
||||
|
||||
func makeWhitePNG(w, h int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func makeContentPNG(w, h, contentX, contentY, contentW, contentH int) []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
for y := contentY; y < contentY+contentH; y++ {
|
||||
for x := contentX; x < contentX+contentW; x++ {
|
||||
img.Set(x, y, color.RGBA{R: 100, G: 50, B: 30, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func imageDims(data []byte) (int, int) {
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
b := img.Bounds()
|
||||
return b.Dx(), b.Dy()
|
||||
}
|
||||
|
||||
func TestCropToContent_InvalidData(t *testing.T) {
|
||||
result, err := imaging.CropToContent([]byte("not an image"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, []byte("not an image")) {
|
||||
t.Error("expected original bytes returned on decode failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_AllBackground(t *testing.T) {
|
||||
src := makeWhitePNG(100, 100)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_CropsToContent(t *testing.T) {
|
||||
// 200x200 white image with a 20x20 content block at (80,80)
|
||||
src := makeContentPNG(200, 200, 80, 80, 20, 20)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
w, h := imageDims(result)
|
||||
if w >= 200 || h >= 200 {
|
||||
t.Errorf("expected smaller result, got %dx%d", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_PreservesContentWithPadding(t *testing.T) {
|
||||
// 200x200 white image with a 30x30 red block at (85,85)
|
||||
src := makeContentPNG(200, 200, 85, 85, 30, 30)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Result should be smaller than original but include padding
|
||||
w, h := imageDims(result)
|
||||
if w == 0 || h == 0 {
|
||||
t.Error("expected valid image dimensions")
|
||||
}
|
||||
if w >= 200 || h >= 200 {
|
||||
t.Errorf("expected crop, got %dx%d", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_ScalesDownLargeImage(t *testing.T) {
|
||||
// 600x600 image with content filling most of it
|
||||
src := makeContentPNG(600, 600, 10, 10, 580, 580)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
w, h := imageDims(result)
|
||||
if w > 300 || h > 300 {
|
||||
t.Errorf("expected scale-down to ≤300, got %dx%d", w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_ReturnsJPEG(t *testing.T) {
|
||||
src := makeContentPNG(100, 100, 20, 20, 60, 60)
|
||||
result, err := imaging.CropToContent(src)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// JPEG magic bytes: FF D8 FF
|
||||
if len(result) < 3 || result[0] != 0xFF || result[1] != 0xD8 {
|
||||
// Check if we got back original (all-background case returns src)
|
||||
if !bytes.Equal(result, src) {
|
||||
t.Error("expected JPEG output")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCropToContent_JPEGInput(t *testing.T) {
|
||||
// Create a JPEG input
|
||||
img := image.NewRGBA(image.Rect(0, 0, 100, 100))
|
||||
for y := 0; y < 100; y++ {
|
||||
for x := 0; x < 100; x++ {
|
||||
if x > 20 && x < 80 && y > 20 && y < 80 {
|
||||
img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
|
||||
} else {
|
||||
img.Set(x, y, color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = jpeg.Encode(&buf, img, nil)
|
||||
|
||||
result, err := imaging.CropToContent(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Error("expected non-empty result")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/auth"
|
||||
)
|
||||
|
||||
// JWTAuth returns an Echo middleware that requires a valid Bearer JWT.
|
||||
func JWTAuth(secret string) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
header := c.Request().Header.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "missing token"})
|
||||
}
|
||||
tokenStr := strings.TrimPrefix(header, "Bearer ")
|
||||
if _, err := auth.ValidateJWT(tokenStr, secret); err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"})
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/auth"
|
||||
mw "osdb/internal/middleware"
|
||||
)
|
||||
|
||||
const testSecret = "testsecret"
|
||||
|
||||
func okHandler(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
func TestJWTAuth_NoToken_Returns401(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
h := mw.JWTAuth(testSecret)(okHandler)
|
||||
_ = h(c)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAuth_ValidToken_Passes(t *testing.T) {
|
||||
token, _ := auth.GenerateJWT("admin", testSecret)
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
h := mw.JWTAuth(testSecret)(okHandler)
|
||||
if err := h(c); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAuth_InvalidToken_Returns401(t *testing.T) {
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid.token.here")
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
h := mw.JWTAuth(testSecret)(okHandler)
|
||||
_ = h(c)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTAuth_WrongSecret_Returns401(t *testing.T) {
|
||||
token, _ := auth.GenerateJWT("admin", "other-secret")
|
||||
|
||||
e := echo.New()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
|
||||
h := mw.JWTAuth(testSecret)(okHandler)
|
||||
_ = h(c)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
"osdb/internal/imaging"
|
||||
)
|
||||
|
||||
type FruitRepo struct {
|
||||
@@ -26,6 +27,10 @@ func imageURL(fruitID, imageID int) string {
|
||||
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID)
|
||||
}
|
||||
|
||||
func thumbnailURL(fruitID, imageID int) string {
|
||||
return fmt.Sprintf("/api/v1/fruits/%d/images/%d/thumbnail", fruitID, imageID)
|
||||
}
|
||||
|
||||
func mapPgError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
@@ -56,7 +61,14 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, ty
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at
|
||||
`SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at,
|
||||
COALESCE((SELECT array_agg(fs2.synonym ORDER BY fs2.id) FROM fruit_synonyms fs2 WHERE fs2.fruit_id = f.id), '{}') AS synonyms,
|
||||
COALESCE(
|
||||
(SELECT '/api/v1/fruits/' || f.id || '/images/' || fi.id || '/thumbnail'
|
||||
FROM fruit_images fi WHERE fi.fruit_id = f.id AND fi.image_type = 'fruit' ORDER BY fi.id LIMIT 1),
|
||||
(SELECT '/api/v1/publications/' || pfi.publication_id || '/fruit-images/' || pfi.id || '/thumbnail'
|
||||
FROM publication_fruit_images pfi WHERE pfi.fruit_id = f.id ORDER BY pfi.id LIMIT 1)
|
||||
) AS thumbnail_url
|
||||
FROM fruits f
|
||||
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
|
||||
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||
@@ -71,10 +83,13 @@ func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, ty
|
||||
fruits := []domain.Fruit{}
|
||||
for rows.Next() {
|
||||
var f domain.Fruit
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt,
|
||||
&f.Synonyms, &f.ThumbnailURL); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if f.Synonyms == nil {
|
||||
f.Synonyms = []string{}
|
||||
}
|
||||
f.Images = []domain.FruitImage{}
|
||||
fruits = append(fruits, f)
|
||||
}
|
||||
@@ -264,7 +279,6 @@ func (r *FruitRepo) ListImages(ctx context.Context, fruitID int) ([]domain.Fruit
|
||||
}
|
||||
|
||||
func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
|
||||
// verify fruit exists
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
|
||||
return domain.FruitImage{}, err
|
||||
@@ -273,12 +287,14 @@ func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string,
|
||||
return domain.FruitImage{}, handler.ErrNotFound
|
||||
}
|
||||
|
||||
thumbnailData, _ := imaging.CropToContent(data)
|
||||
|
||||
var img domain.FruitImage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO fruit_images (fruit_id, filename, data, image_type, title)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO fruit_images (fruit_id, filename, data, thumbnail_data, image_type, title)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, fruit_id, filename, image_type, title, created_at`,
|
||||
fruitID, filename, data, imageType, title).
|
||||
fruitID, filename, data, thumbnailData, imageType, title).
|
||||
Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.FruitImage{}, err
|
||||
@@ -300,6 +316,24 @@ func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]b
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetThumbnailData returns thumbnail_data if present, falls back to data.
|
||||
func (r *FruitRepo) GetThumbnailData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
|
||||
var thumbnailData, data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT thumbnail_data, data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).
|
||||
Scan(&thumbnailData, &data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(thumbnailData) > 0 {
|
||||
return thumbnailData, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
|
||||
if err != nil {
|
||||
@@ -310,3 +344,40 @@ func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackfillThumbnails generates thumbnail_data for all fruit_images rows missing it.
|
||||
// Returns count of rows processed.
|
||||
func (r *FruitRepo) BackfillThumbnails(ctx context.Context) (int, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT id, fruit_id, data FROM fruit_images WHERE thumbnail_data IS NULL`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
id, fruitID int
|
||||
data []byte
|
||||
}
|
||||
var pending []row
|
||||
for rows.Next() {
|
||||
var rw row
|
||||
if err := rows.Scan(&rw.id, &rw.fruitID, &rw.data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pending = append(pending, rw)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, rw := range pending {
|
||||
thumb, _ := imaging.CropToContent(rw.data)
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
"osdb/internal/imaging"
|
||||
)
|
||||
|
||||
type PublicationRepo struct {
|
||||
@@ -332,12 +333,15 @@ func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int,
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return domain.PublicationFruitImage{}, err
|
||||
}
|
||||
|
||||
thumbnailData, _ := imaging.CropToContent(data)
|
||||
|
||||
var img domain.PublicationFruitImage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, image_type)
|
||||
VALUES ($1, $2, $3, $4, 'fruit')
|
||||
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, thumbnail_data, image_type)
|
||||
VALUES ($1, $2, $3, $4, $5, 'fruit')
|
||||
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`,
|
||||
pubID, fruitID, filename, data).
|
||||
pubID, fruitID, filename, data, thumbnailData).
|
||||
Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.PublicationFruitImage{}, err
|
||||
@@ -357,6 +361,24 @@ func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID in
|
||||
return data, err
|
||||
}
|
||||
|
||||
// GetFruitImageThumbnailData returns thumbnail_data if present, falls back to data.
|
||||
func (r *PublicationRepo) GetFruitImageThumbnailData(ctx context.Context, pubID, imgID int) ([]byte, error) {
|
||||
var thumbnailData, data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT thumbnail_data, data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
|
||||
imgID, pubID).Scan(&thumbnailData, &data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(thumbnailData) > 0 {
|
||||
return thumbnailData, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) DeleteFruitImage(ctx context.Context, pubID, imgID int) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID)
|
||||
@@ -426,3 +448,41 @@ func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackfillThumbnails generates thumbnail_data for all publication_fruit_images rows missing it.
|
||||
// Returns count of rows processed.
|
||||
func (r *PublicationRepo) BackfillThumbnails(ctx context.Context) (int, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, data FROM publication_fruit_images WHERE thumbnail_data IS NULL`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
id int
|
||||
data []byte
|
||||
}
|
||||
var pending []row
|
||||
for rows.Next() {
|
||||
var rw row
|
||||
if err := rows.Scan(&rw.id, &rw.data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
pending = append(pending, rw)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, rw := range pending {
|
||||
thumb, _ := imaging.CropToContent(rw.data)
|
||||
if _, err := r.pool.Exec(ctx,
|
||||
`UPDATE publication_fruit_images SET thumbnail_data=$1 WHERE id=$2`, thumb, rw.id); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE fruit_images DROP COLUMN thumbnail_data;
|
||||
ALTER TABLE publication_fruit_images DROP COLUMN thumbnail_data;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE fruit_images ADD COLUMN thumbnail_data BYTEA;
|
||||
ALTER TABLE publication_fruit_images ADD COLUMN thumbnail_data BYTEA;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE fruits ALTER COLUMN osdb_number TYPE VARCHAR(50);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE fruits ALTER COLUMN osdb_number TYPE VARCHAR(100);
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBar />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const resp = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error('Invalid credentials')
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const TOKEN_KEY = 'auth_token'
|
||||
|
||||
export function bearerHeader(): Record<string, string> {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleUnauthorized(): never {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
window.location.href = '/login'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export interface Fruit {
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
images: FruitImage[]
|
||||
thumbnail_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -61,8 +62,11 @@ export function imageUrl(fruitId: number, imageId: number): string {
|
||||
return `/api/v1/fruits/${fruitId}/images/${imageId}`
|
||||
}
|
||||
|
||||
import { bearerHeader, handleUnauthorized } from './authHeader'
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -99,7 +103,7 @@ export async function getFruit(id: number): Promise<Fruit> {
|
||||
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch('/api/v1/fruits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
@@ -108,14 +112,14 @@ export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteFruit(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -135,11 +139,11 @@ export async function addImage(
|
||||
form.append('image_type', imageType)
|
||||
if (title) form.append('title', title)
|
||||
// Do NOT set Content-Type — browser sets it with the correct multipart boundary
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,11 @@ export function pubFruitImageUrl(pubId: number, imgId: number): string {
|
||||
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
|
||||
}
|
||||
|
||||
import { bearerHeader, handleUnauthorized } from './authHeader'
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -83,7 +86,7 @@ export async function getPublication(id: number): Promise<Publication> {
|
||||
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch('/api/v1/publications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
@@ -92,26 +95,26 @@ export async function createPublication(dto: PublicationWriteDTO): Promise<Publi
|
||||
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePublication(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function deleteCoverImage(pubId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -123,14 +126,14 @@ export async function listPubFruits(pubId: number): Promise<PublicationFruit[]>
|
||||
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify({ fruit_id: fruitId }),
|
||||
})
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -143,12 +146,12 @@ export async function uploadDescription(pubId: number, fruitId: number, file: Fi
|
||||
const form = new FormData()
|
||||
form.append('pdf', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -161,12 +164,12 @@ export async function uploadPubFruitImage(pubId: number, fruitId: number, file:
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Fruit } from '../api/fruits'
|
||||
|
||||
defineProps<{ fruit: Fruit }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/fruits/${fruit.id}`"
|
||||
class="block bg-white border border-gray-200 rounded-lg overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
<img
|
||||
v-if="fruit.thumbnail_url"
|
||||
:src="fruit.thumbnail_url"
|
||||
:alt="fruit.name"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
<span v-else class="text-gray-300 text-4xl select-none">🌿</span>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="font-semibold text-gray-900 truncate">{{ fruit.name }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ fruit.osdb_number }}</p>
|
||||
<p class="text-xs text-green-700 mt-0.5">{{ fruit.fruit_type }}</p>
|
||||
<p v-if="fruit.synonyms.length" class="text-xs text-gray-400 mt-1 truncate">
|
||||
{{ fruit.synonyms.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6 text-sm">
|
||||
<RouterLink to="/fruits" class="font-medium text-gray-700 hover:text-green-700">Früchte</RouterLink>
|
||||
<RouterLink to="/publications" class="font-medium text-gray-700 hover:text-green-700">Publikationen</RouterLink>
|
||||
<RouterLink v-if="auth.isLoggedIn" to="/admin" class="font-medium text-gray-700 hover:text-green-700">Administration</RouterLink>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
v-if="auth.isLoggedIn"
|
||||
@click="logout"
|
||||
class="text-gray-600 hover:text-red-600 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
<RouterLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="text-gray-600 hover:text-green-700 transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import HelloWorld from '../views/HelloWorld.vue'
|
||||
import FruitList from '../views/FruitList.vue'
|
||||
import FruitCreate from '../views/FruitCreate.vue'
|
||||
@@ -6,6 +7,8 @@ import FruitDetail from '../views/FruitDetail.vue'
|
||||
import PublicationList from '../views/PublicationList.vue'
|
||||
import PublicationCreate from '../views/PublicationCreate.vue'
|
||||
import PublicationDetail from '../views/PublicationDetail.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -14,14 +17,19 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: HelloWorld,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginView,
|
||||
},
|
||||
{
|
||||
path: '/fruits',
|
||||
component: FruitList,
|
||||
},
|
||||
{
|
||||
// /fruits/new must come before /:id so vue-router v5 doesn't capture "new" as a param
|
||||
// /fruits/new must come before /:id so vue-router doesn't capture "new" as a param
|
||||
path: '/fruits/new',
|
||||
component: FruitCreate,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/fruits/:id',
|
||||
@@ -36,13 +44,28 @@ const router = createRouter({
|
||||
// /publications/new must come before /:id
|
||||
path: '/publications/new',
|
||||
component: PublicationCreate,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/publications/:id',
|
||||
component: PublicationDetail,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
function makeLocalStorageMock() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', makeLocalStorageMock())
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts unauthenticated when localStorage is empty', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('restores auth state from localStorage on init', () => {
|
||||
localStorage.setItem('auth_token', 'my-stored-token')
|
||||
const store = useAuthStore()
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
expect(store.token).toBe('my-stored-token')
|
||||
})
|
||||
|
||||
it('login sets token and isLoggedIn on success', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'new-jwt-token' }),
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login('admin', 'secret')
|
||||
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
expect(store.token).toBe('new-jwt-token')
|
||||
expect(localStorage.getItem('auth_token')).toBe('new-jwt-token')
|
||||
})
|
||||
|
||||
it('login throws on invalid credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: false })
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login('admin', 'wrong')).rejects.toThrow()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears token and isLoggedIn', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'tok' }),
|
||||
})
|
||||
const store = useAuthStore()
|
||||
await store.login('admin', 'secret')
|
||||
store.logout()
|
||||
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as apiLogin } from '../api/auth'
|
||||
|
||||
const TOKEN_KEY = 'auth_token'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const isLoggedIn = computed(() => token.value !== null)
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const resp = await apiLogin(username, password)
|
||||
token.value = resp.token
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, login, logout }
|
||||
})
|
||||
@@ -11,6 +11,7 @@ const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
|
||||
fruit_type: 'Apfelsorten',
|
||||
synonyms: [],
|
||||
images: [],
|
||||
thumbnail_url: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { bearerHeader, handleUnauthorized } from '../api/authHeader'
|
||||
|
||||
const running = ref(false)
|
||||
const result = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function backfill() {
|
||||
running.value = true
|
||||
result.value = null
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch('/api/v1/admin/backfill-thumbnails', {
|
||||
method: 'POST',
|
||||
headers: bearerHeader(),
|
||||
})
|
||||
if (resp.status === 401) handleUnauthorized()
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
result.value = JSON.stringify(data, null, 2)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Fehler'
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Administration</h1>
|
||||
|
||||
<section class="border rounded p-4 bg-gray-50 space-y-3">
|
||||
<h2 class="font-semibold text-gray-800">Thumbnails rückfüllen</h2>
|
||||
<p class="text-sm text-gray-600">Erzeugt Thumbnails für alle Bilder, die noch keines haben.</p>
|
||||
<button
|
||||
@click="backfill"
|
||||
:disabled="running"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm"
|
||||
>
|
||||
{{ running ? 'Läuft...' : 'Starten' }}
|
||||
</button>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
<pre v-if="result" class="text-xs bg-white border rounded p-2 overflow-auto">{{ result }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
|
||||
import type { Fruit } from '../api/fruits'
|
||||
import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications'
|
||||
@@ -10,6 +11,7 @@ import type { PublicationDescription, PublicationFruitImage } from '../api/publi
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const store = useFruitStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const editing = ref(false)
|
||||
const editName = ref('')
|
||||
@@ -134,7 +136,7 @@ async function removeImage(imageId: number) {
|
||||
<div v-else>
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex gap-2">
|
||||
<button
|
||||
v-if="!editing"
|
||||
@click="editing = true"
|
||||
@@ -229,7 +231,7 @@ async function removeImage(imageId: number) {
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden">
|
||||
<img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" />
|
||||
<div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div v-if="auth.isLoggedIn" class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click="removeImage(img.id)"
|
||||
class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700"
|
||||
@@ -242,7 +244,7 @@ async function removeImage(imageId: number) {
|
||||
</div>
|
||||
|
||||
<!-- Upload form -->
|
||||
<div class="border rounded p-4 bg-gray-50">
|
||||
<div v-if="auth.isLoggedIn" class="border rounded p-4 bg-gray-50">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3>
|
||||
<div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { onMounted, onUnmounted, computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import FruitCard from '../components/FruitCard.vue'
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
{ label: '(Alle)', value: '' },
|
||||
@@ -29,6 +31,7 @@ const SEARCH_TYPES = [
|
||||
]
|
||||
|
||||
const store = useFruitStore()
|
||||
const auth = useAuthStore()
|
||||
const nameInput = ref(store.searchName)
|
||||
const typeSelect = ref(store.searchType)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -75,6 +78,7 @@ function onTypeChange() {
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Früchte</h1>
|
||||
<RouterLink
|
||||
v-if="auth.isLoggedIn"
|
||||
to="/fruits/new"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
@@ -105,35 +109,15 @@ function onTypeChange() {
|
||||
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
|
||||
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||
<div v-else>
|
||||
<table class="w-full border-collapse border border-gray-200 rounded">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th>
|
||||
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th>
|
||||
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="store.fruits.length === 0">
|
||||
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
|
||||
<div v-if="store.fruits.length === 0" class="text-center text-gray-400 py-16">
|
||||
Keine Früchte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="fruit in store.fruits"
|
||||
:key="fruit.id"
|
||||
class="hover:bg-gray-50 cursor-pointer"
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
||||
>
|
||||
<td class="border border-gray-200 px-4 py-2">
|
||||
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline">
|
||||
{{ fruit.name }}
|
||||
</RouterLink>
|
||||
</td>
|
||||
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.osdb_number }}</td>
|
||||
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.fruit_type }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<FruitCard v-for="fruit in store.fruits" :key="fruit.id" :fruit="fruit" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
|
||||
<span>{{ store.total }} Früchte gesamt</span>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/fruits'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
error.value = 'Ungültige Zugangsdaten'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="w-full max-w-sm bg-white rounded shadow p-8 space-y-5"
|
||||
>
|
||||
<h1 class="text-xl font-bold text-gray-900">Anmelden</h1>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? 'Anmelden...' : 'Anmelden' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePublicationStore } from '../stores/publicationStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import {
|
||||
uploadCoverImage,
|
||||
deleteCoverImage,
|
||||
@@ -23,6 +24,7 @@ import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue'
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const store = usePublicationStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Edit state
|
||||
const editing = ref(false)
|
||||
@@ -212,7 +214,7 @@ async function deleteFruitImg(imgId: number) {
|
||||
<p class="text-sm text-gray-500 font-mono">{{ store.current.osdb_pub_id }}</p>
|
||||
<p v-if="store.current.author" class="text-gray-700">{{ store.current.author }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex gap-2">
|
||||
<button
|
||||
class="rounded border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
@click="editing = !editing"
|
||||
@@ -230,7 +232,7 @@ async function deleteFruitImg(imgId: number) {
|
||||
</div>
|
||||
|
||||
<!-- Edit form -->
|
||||
<div v-if="editing" class="mb-6 rounded border p-4">
|
||||
<div v-if="auth.isLoggedIn && editing" class="mb-6 rounded border p-4">
|
||||
<div v-if="serverError" class="mb-3 text-red-600">{{ serverError }}</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
@@ -266,6 +268,7 @@ async function deleteFruitImg(imgId: number) {
|
||||
@click="overlayOpen = true"
|
||||
/>
|
||||
<button
|
||||
v-if="auth.isLoggedIn"
|
||||
class="rounded border px-2 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||
@click="removeCover"
|
||||
>
|
||||
@@ -274,7 +277,7 @@ async function deleteFruitImg(imgId: number) {
|
||||
</div>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Kein Titelbild vorhanden.</div>
|
||||
<div v-if="coverError" class="mb-2 text-red-600 text-sm">{{ coverError }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -297,13 +300,13 @@ async function deleteFruitImg(imgId: number) {
|
||||
<ul v-if="fruits.length" class="mb-3 space-y-1">
|
||||
<li v-for="f in fruits" :key="f.fruit_id" class="flex items-center justify-between rounded border px-3 py-1 text-sm">
|
||||
<span>{{ f.name }} <span class="text-gray-400 font-mono text-xs">({{ f.osdb_number }})</span></span>
|
||||
<button class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
|
||||
<button v-if="auth.isLoggedIn" class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Keine Früchte verknüpft.</div>
|
||||
<div class="flex gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex gap-2">
|
||||
<input
|
||||
v-model="linkFruitId"
|
||||
type="number"
|
||||
@@ -332,14 +335,14 @@ async function deleteFruitImg(imgId: number) {
|
||||
<a :href="d.url" target="_blank" class="text-blue-600 hover:underline">
|
||||
{{ d.fruit_name || `Frucht #${d.fruit_id}` }}
|
||||
</a>
|
||||
<button class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
|
||||
<button v-if="auth.isLoggedIn" class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
|
||||
Löschen
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Keine Beschreibungen vorhanden.</div>
|
||||
<div v-if="descError" class="mb-2 text-red-600 text-sm">{{ descError }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="descFruitId"
|
||||
type="number"
|
||||
@@ -377,14 +380,14 @@ async function deleteFruitImg(imgId: number) {
|
||||
class="h-24 w-24 rounded border object-cover"
|
||||
/>
|
||||
<span class="text-xs text-gray-500">Frucht #{{ img.fruit_id }}</span>
|
||||
<button class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
|
||||
<button v-if="auth.isLoggedIn" class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Keine Fruchtbilder vorhanden.</div>
|
||||
<div v-if="fruitImgError" class="mb-2 text-red-600 text-sm">{{ fruitImgError }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="fruitImgFruitId"
|
||||
type="number"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { usePublicationStore } from '../stores/publicationStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const store = usePublicationStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -18,6 +20,7 @@ onMounted(async () => {
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Publikationen</h1>
|
||||
<router-link
|
||||
v-if="auth.isLoggedIn"
|
||||
to="/publications/new"
|
||||
class="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Import Scripts
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Postgres running and migrated (`make migrate-up`)
|
||||
- `DATABASE_URL` set, e.g.:
|
||||
```
|
||||
export DATABASE_URL=postgres://user:pass@localhost:5432/osdb
|
||||
```
|
||||
- `03-data/` directory present at repo root
|
||||
- `psycopg2` installed (`pip install psycopg2-binary`)
|
||||
|
||||
---
|
||||
|
||||
## Run Order
|
||||
|
||||
Scripts must be run in order — publications import depends on fruits being present.
|
||||
|
||||
### 1. Import fruits
|
||||
|
||||
```bash
|
||||
DATABASE_URL=... python3 scripts/import_fruits.py
|
||||
```
|
||||
|
||||
Imports fruits, synonyms, and fruit images from `03-data/obstsorten.xml`.
|
||||
Idempotent: upserts fruits by `osdb_number`.
|
||||
|
||||
### 2. Import publications
|
||||
|
||||
```bash
|
||||
DATABASE_URL=... python3 scripts/import_publications.py
|
||||
```
|
||||
|
||||
Imports publications from `03-data/osws.xml` (only `<obj>` elements with `<osw>=1`).
|
||||
For each publication: upserts the row, loads cover image, links fruits found by
|
||||
filesystem scan of `03-data/osdb/{pubId}/`, and imports PDFs and fruit images.
|
||||
Idempotent: clears and re-inserts linked data on re-run.
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: ./scripts/create_user.sh <username> <password>
|
||||
# Prints a line suitable for appending to users.env
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <username> <password>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USERNAME="$1"
|
||||
PASSWORD="$2"
|
||||
|
||||
# bcrypt the peppered input "username:password"
|
||||
PEPPERED="${USERNAME}:${PASSWORD}"
|
||||
|
||||
# Try Python 3 with bcrypt first, then fall back to htpasswd
|
||||
if python3 -c "import bcrypt" 2>/dev/null; then
|
||||
HASH=$(python3 -c "
|
||||
import bcrypt, sys
|
||||
pw = sys.argv[1].encode()
|
||||
print(bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12)).decode())
|
||||
" "$PEPPERED")
|
||||
elif command -v htpasswd &>/dev/null; then
|
||||
HASH=$(htpasswd -bnBC 12 "" "$PEPPERED" | tr -d ':\n' | sed 's/^\$/\$/')
|
||||
else
|
||||
echo "Error: install 'bcrypt' (pip install bcrypt) or 'htpasswd' (apache2-utils)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${USERNAME}:${HASH}"
|
||||
@@ -51,7 +51,10 @@ TYP_MAP = {
|
||||
|
||||
IMAGE_DIRS = [
|
||||
("einzelfruechte", "fruit"),
|
||||
("osdb/fru", "fruit"),
|
||||
("osdb/frucht", "fruit"),
|
||||
("blueten", "flower"),
|
||||
("osdb/blu", "flower"),
|
||||
("baume", "tree"),
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# username:bcrypt_hash — generate entries with: ./scripts/create_user.sh <username> <password>
|
||||
# admin:$2a$12$...
|
||||
Reference in New Issue
Block a user