Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/auth_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package main

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)

const (
userIDContextKey = "user_id"
userEmailContextKey = "user_email"
)

func authMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authorization := strings.TrimSpace(c.GetHeader("Authorization"))
if authorization == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
return
}

parts := strings.SplitN(authorization, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"})
return
}

claims := &jwtClaims{}
token, err := jwt.ParseWithClaims(
parts[1],
claims,
func(_ *jwt.Token) (any, error) {
return []byte(jwtSecret()), nil
},
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
)
if err != nil || token == nil || !token.Valid {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}

if claims.UserID <= 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token claims"})
return
}

c.Set(userIDContextKey, claims.UserID)
c.Set(userEmailContextKey, claims.Email)
c.Next()
}
}

func currentUserID(c *gin.Context) (int64, bool) {
val, ok := c.Get(userIDContextKey)
if !ok {
return 0, false
}

userID, ok := val.(int64)
return userID, ok && userID > 0
}
3 changes: 3 additions & 0 deletions backend/internal/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ func RunMigrations(db *sql.DB) error {
migrations := []migration{
usersTableMigration(),
usersGoogleOAuthMigration(),
memoryPalacesTableMigration(),
memoryPalaceObjectsTableMigration(),
backfillMemoryPalaceElementsToObjectsMigration(),
}

for _, m := range migrations {
Expand Down
96 changes: 96 additions & 0 deletions backend/internal/database/migrations_objects.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package database

import (
"database/sql"
"errors"
)

func memoryPalaceObjectsTableMigration() migration {
return migration{
name: "create_memory_palace_objects_table",
run: createMemoryPalaceObjectsTable,
}
}

func backfillMemoryPalaceElementsToObjectsMigration() migration {
return migration{
name: "backfill_memory_palace_elements_to_objects",
run: backfillMemoryPalaceElementsToObjects,
}
}

func createMemoryPalaceObjectsTable(db *sql.DB) error {
const query = `
CREATE TABLE IF NOT EXISTS memory_palace_objects (
id BIGSERIAL PRIMARY KEY,
palace_id BIGINT NOT NULL REFERENCES memory_palaces(id) ON DELETE CASCADE,
type TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS memory_palace_objects_palace_id_idx
ON memory_palace_objects (palace_id);

CREATE INDEX IF NOT EXISTS memory_palace_objects_palace_sort_idx
ON memory_palace_objects (palace_id, sort_order);
`

_, err := db.Exec(query)
return err
}

func backfillMemoryPalaceElementsToObjects(db *sql.DB) error {
// One-time bridge migration: older builds stored the full elements list on
// memory_palaces.elements. Newer builds store each element as a row in
// memory_palace_objects. To avoid losing data, copy elements into objects for
// any palace that has elements but no objects yet.
const query = `
INSERT INTO memory_palace_objects (palace_id, type, data, sort_order)
SELECT
p.id,
COALESCE(elem ->> 'type', 'unknown') AS type,
elem AS data,
(ordinality - 1)::integer AS sort_order
FROM memory_palaces p
CROSS JOIN LATERAL jsonb_array_elements(p.elements) WITH ORDINALITY AS t(elem, ordinality)
WHERE jsonb_typeof(p.elements) = 'array'
AND NOT EXISTS (
SELECT 1
FROM memory_palace_objects o
WHERE o.palace_id = p.id
);
`

_, err := db.Exec(query)
return err
}

func memoryPalaceObjectsSchemaCheck() schemaCheck {
return schemaCheck{
name: "memory_palace_objects_table_exists",
validate: ensureMemoryPalaceObjectsTableExists,
}
}

func ensureMemoryPalaceObjectsTableExists(db *sql.DB) error {
const query = `
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'memory_palace_objects'
);
`

var exists bool
if err := db.QueryRow(query).Scan(&exists); err != nil {
return err
}
if !exists {
return errors.New("required table 'memory_palace_objects' does not exist; run migrations with: go run ./cmd/migrate")
}

return nil
}
61 changes: 61 additions & 0 deletions backend/internal/database/migrations_palaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package database

import (
"database/sql"
"errors"
)

func memoryPalacesTableMigration() migration {
return migration{
name: "create_memory_palaces_table",
run: createMemoryPalacesTable,
}
}

func createMemoryPalacesTable(db *sql.DB) error {
const query = `
CREATE TABLE IF NOT EXISTS memory_palaces (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'public', 'unlisted')),
elements JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
Comment on lines +18 to +25
);

CREATE INDEX IF NOT EXISTS memory_palaces_user_id_idx
ON memory_palaces (user_id);
`

_, err := db.Exec(query)
return err
}

func memoryPalacesSchemaCheck() schemaCheck {
return schemaCheck{
name: "memory_palaces_table_exists",
validate: ensureMemoryPalacesTableExists,
}
}

func ensureMemoryPalacesTableExists(db *sql.DB) error {
const query = `
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'memory_palaces'
);
`

var exists bool
if err := db.QueryRow(query).Scan(&exists); err != nil {
return err
}
if !exists {
return errors.New("required table 'memory_palaces' does not exist; run migrations with: go run ./cmd/migrate")
}

return nil
}
2 changes: 2 additions & 0 deletions backend/internal/database/schema_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ func ValidateRequiredSchema(db *sql.DB) error {
checks := []schemaCheck{
usersSchemaCheck(),
usersGoogleOAuthSchemaCheck(),
memoryPalacesSchemaCheck(),
memoryPalaceObjectsSchemaCheck(),
}

for _, c := range checks {
Expand Down
11 changes: 11 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,17 @@ func main() {
})
})

auth := r.Group("/", authMiddleware())
auth.GET("/palaces", listPalacesHandler(db))
auth.POST("/palaces", createPalaceHandler(db))
auth.GET("/palaces/:id", getPalaceHandler(db))
auth.PUT("/palaces/:id", updatePalaceHandler(db))
auth.DELETE("/palaces/:id", deletePalaceHandler(db))
auth.GET("/palaces/:id/objects", listPalaceObjectsHandler(db))
auth.POST("/palaces/:id/objects", createPalaceObjectHandler(db))
auth.PUT("/objects/:objectId", updateObjectHandler(db))
auth.DELETE("/objects/:objectId", deleteObjectHandler(db))

port := os.Getenv("PORT")
if port == "" {
port = "8080"
Expand Down
Loading