Major changes:
- Add auth-svc: JWT auth, register/login/refresh, password reset
- Add auth UI: modals, pages (/login, /register, /forgot-password)
- Add usage tracking (usage_metrics table, daily limits)
- Add tiered rate limiting (free/pro/business)
- Add LLM usage limits per tier
Security fixes:
- All repos now require userID for Update/Delete operations
- JWT middleware in chat-svc, llm-svc, agent-svc, discover-svc
- ErrNotFound/ErrForbidden errors for proper access control
Cleanup:
- Remove legacy TypeScript services/ directory
- Remove computer-svc (to be reimplemented)
- Remove old deploy/docker configs
New files:
- backend/cmd/auth-svc/main.go
- backend/internal/auth/{types,repository}.go
- backend/internal/usage/{types,repository}.go
- backend/pkg/middleware/{llm_limits,ratelimit_tiered}.go
- backend/webui/src/components/auth/*
- backend/webui/src/app/(auth)/*
Made-with: Cursor
311 lines
7.9 KiB
Go
311 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
|
"github.com/gofiber/fiber/v2/middleware/logger"
|
|
"github.com/gooseek/backend/internal/db"
|
|
"github.com/gooseek/backend/internal/files"
|
|
"github.com/gooseek/backend/internal/llm"
|
|
"github.com/gooseek/backend/pkg/config"
|
|
"github.com/gooseek/backend/pkg/middleware"
|
|
)
|
|
|
|
func main() {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
log.Fatal("Failed to load config:", err)
|
|
}
|
|
|
|
var database *db.PostgresDB
|
|
var fileRepo *db.FileRepository
|
|
|
|
if cfg.DatabaseURL != "" {
|
|
database, err = db.NewPostgresDB(cfg.DatabaseURL)
|
|
if err != nil {
|
|
log.Printf("Database unavailable: %v", err)
|
|
} else {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
if err := database.RunMigrations(ctx); err != nil {
|
|
log.Printf("Migration warning: %v", err)
|
|
}
|
|
cancel()
|
|
defer database.Close()
|
|
|
|
fileRepo = db.NewFileRepository(database)
|
|
log.Println("PostgreSQL connected")
|
|
}
|
|
}
|
|
|
|
var llmClient llm.Client
|
|
if cfg.OpenAIAPIKey != "" {
|
|
llmClient, err = llm.NewClient(llm.ProviderConfig{
|
|
ProviderID: "openai",
|
|
ModelKey: "gpt-4o",
|
|
APIKey: cfg.OpenAIAPIKey,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to create OpenAI client: %v", err)
|
|
}
|
|
} else if cfg.AnthropicAPIKey != "" {
|
|
llmClient, err = llm.NewClient(llm.ProviderConfig{
|
|
ProviderID: "anthropic",
|
|
ModelKey: "claude-3-5-sonnet-20241022",
|
|
APIKey: cfg.AnthropicAPIKey,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Failed to create Anthropic client: %v", err)
|
|
}
|
|
}
|
|
|
|
storagePath := os.Getenv("FILE_STORAGE_PATH")
|
|
if storagePath == "" {
|
|
storagePath = "/tmp/gooseek-files"
|
|
}
|
|
|
|
var fileAnalyzer *files.FileAnalyzer
|
|
if llmClient != nil {
|
|
fileAnalyzer = files.NewFileAnalyzer(llmClient, storagePath)
|
|
}
|
|
|
|
app := fiber.New(fiber.Config{
|
|
BodyLimit: 100 * 1024 * 1024,
|
|
ReadTimeout: 60 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
})
|
|
|
|
app.Use(logger.New())
|
|
app.Use(cors.New())
|
|
|
|
if cfg.JWTSecret != "" || cfg.AuthSvcURL != "" {
|
|
app.Use(middleware.JWT(middleware.JWTConfig{
|
|
Secret: cfg.JWTSecret,
|
|
AuthSvcURL: cfg.AuthSvcURL,
|
|
AllowGuest: false,
|
|
SkipPaths: []string{"/health", "/ready"},
|
|
}))
|
|
}
|
|
|
|
app.Get("/health", func(c *fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{"status": "ok"})
|
|
})
|
|
|
|
app.Get("/ready", func(c *fiber.Ctx) error {
|
|
return c.JSON(fiber.Map{"status": "ready"})
|
|
})
|
|
|
|
api := app.Group("/api/v1/files")
|
|
|
|
api.Post("/upload", func(c *fiber.Ctx) error {
|
|
if fileRepo == nil || fileAnalyzer == nil {
|
|
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
|
|
}
|
|
|
|
userID := middleware.GetUserID(c)
|
|
if userID == "" {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
|
|
}
|
|
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "No file uploaded"})
|
|
}
|
|
|
|
if file.Size > 50*1024*1024 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "File too large (max 50MB)"})
|
|
}
|
|
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to read file"})
|
|
}
|
|
defer f.Close()
|
|
|
|
storagePath, fileSize, err := fileAnalyzer.SaveFile(file.Filename, f)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to save file"})
|
|
}
|
|
|
|
buf := make([]byte, 512)
|
|
f.Seek(0, 0)
|
|
f.Read(buf)
|
|
mimeType := files.DetectMimeType(file.Filename, buf)
|
|
|
|
uploadedFile := &db.UploadedFile{
|
|
UserID: userID,
|
|
Filename: file.Filename,
|
|
FileType: mimeType,
|
|
FileSize: fileSize,
|
|
StoragePath: storagePath,
|
|
Metadata: map[string]interface{}{},
|
|
}
|
|
|
|
if err := fileRepo.Create(c.Context(), uploadedFile); err != nil {
|
|
fileAnalyzer.DeleteFile(storagePath)
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to save file record"})
|
|
}
|
|
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
result, err := fileAnalyzer.AnalyzeFile(ctx, storagePath, mimeType)
|
|
if err != nil {
|
|
log.Printf("File analysis failed for %s: %v", uploadedFile.ID, err)
|
|
return
|
|
}
|
|
|
|
fileRepo.UpdateExtractedText(ctx, uploadedFile.ID, result.ExtractedText, uploadedFile.UserID)
|
|
}()
|
|
|
|
return c.Status(201).JSON(fiber.Map{
|
|
"id": uploadedFile.ID,
|
|
"filename": uploadedFile.Filename,
|
|
"fileType": uploadedFile.FileType,
|
|
"fileSize": uploadedFile.FileSize,
|
|
"status": "processing",
|
|
})
|
|
})
|
|
|
|
api.Get("/", func(c *fiber.Ctx) error {
|
|
if fileRepo == nil {
|
|
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
|
|
}
|
|
|
|
userID := middleware.GetUserID(c)
|
|
if userID == "" {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
|
|
}
|
|
|
|
limit := c.QueryInt("limit", 50)
|
|
offset := c.QueryInt("offset", 0)
|
|
|
|
files, err := fileRepo.GetByUserID(c.Context(), userID, limit, offset)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to get files"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"files": files})
|
|
})
|
|
|
|
api.Get("/:id", func(c *fiber.Ctx) error {
|
|
if fileRepo == nil {
|
|
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
|
|
}
|
|
|
|
fileID := c.Params("id")
|
|
userID := middleware.GetUserID(c)
|
|
|
|
file, err := fileRepo.GetByID(c.Context(), fileID)
|
|
if err != nil || file == nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
|
|
}
|
|
|
|
if file.UserID != userID {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
|
}
|
|
|
|
return c.JSON(file)
|
|
})
|
|
|
|
api.Get("/:id/content", func(c *fiber.Ctx) error {
|
|
if fileRepo == nil {
|
|
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
|
|
}
|
|
|
|
fileID := c.Params("id")
|
|
userID := middleware.GetUserID(c)
|
|
|
|
file, err := fileRepo.GetByID(c.Context(), fileID)
|
|
if err != nil || file == nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
|
|
}
|
|
|
|
if file.UserID != userID {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"id": file.ID,
|
|
"filename": file.Filename,
|
|
"extractedText": file.ExtractedText,
|
|
})
|
|
})
|
|
|
|
api.Post("/:id/analyze", func(c *fiber.Ctx) error {
|
|
if fileRepo == nil || fileAnalyzer == nil {
|
|
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
|
|
}
|
|
|
|
fileID := c.Params("id")
|
|
userID := middleware.GetUserID(c)
|
|
|
|
file, err := fileRepo.GetByID(c.Context(), fileID)
|
|
if err != nil || file == nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
|
|
}
|
|
|
|
if file.UserID != userID {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
|
}
|
|
|
|
result, err := fileAnalyzer.AnalyzeFile(c.Context(), file.StoragePath, file.FileType)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Analysis failed: " + err.Error()})
|
|
}
|
|
|
|
fileRepo.UpdateExtractedText(c.Context(), fileID, result.ExtractedText, userID)
|
|
|
|
return c.JSON(result)
|
|
})
|
|
|
|
api.Delete("/:id", func(c *fiber.Ctx) error {
|
|
if fileRepo == nil || fileAnalyzer == nil {
|
|
return c.Status(503).JSON(fiber.Map{"error": "Service unavailable"})
|
|
}
|
|
|
|
fileID := c.Params("id")
|
|
userID := middleware.GetUserID(c)
|
|
|
|
file, err := fileRepo.GetByID(c.Context(), fileID)
|
|
if err != nil || file == nil {
|
|
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
|
|
}
|
|
|
|
if file.UserID != userID {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Access denied"})
|
|
}
|
|
|
|
fileAnalyzer.DeleteFile(file.StoragePath)
|
|
|
|
if err := fileRepo.Delete(c.Context(), fileID, userID); err != nil {
|
|
if err == db.ErrNotFound {
|
|
return c.Status(404).JSON(fiber.Map{"error": "File not found"})
|
|
}
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete file"})
|
|
}
|
|
|
|
return c.Status(204).Send(nil)
|
|
})
|
|
|
|
port := getEnvInt("FILE_SVC_PORT", 3026)
|
|
log.Printf("file-svc listening on :%d", port)
|
|
log.Fatal(app.Listen(fmt.Sprintf(":%d", port)))
|
|
}
|
|
|
|
func getEnvInt(key string, defaultValue int) int {
|
|
if val := os.Getenv(key); val != "" {
|
|
var result int
|
|
if _, err := fmt.Sscanf(val, "%d", &result); err == nil {
|
|
return result
|
|
}
|
|
}
|
|
return defaultValue
|
|
}
|