5d92820591
Timezone is now sent from the client on each request that needs it, instead of storing it in the database. This simplifies the model and allows timezone to be dynamic (useful for traveling users). Changes: - Remove timezone field from User entity - Remove timezone from user registration - GET /habits/today now requires ?timezone= query param - Add migration to drop timezone column from database - Update related tests
169 lines
6.3 KiB
Go
169 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"apocapoc-api/internal/application/commands"
|
|
"apocapoc-api/internal/application/queries"
|
|
"apocapoc-api/internal/i18n"
|
|
"apocapoc-api/internal/infrastructure/auth"
|
|
"apocapoc-api/internal/infrastructure/config"
|
|
"apocapoc-api/internal/infrastructure/crypto"
|
|
"apocapoc-api/internal/infrastructure/email"
|
|
httpInfra "apocapoc-api/internal/infrastructure/http"
|
|
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
|
)
|
|
|
|
// @title Apocapoc API
|
|
// @description Self-hosted habit tracking service
|
|
// @termsOfService http://swagger.io/terms/
|
|
|
|
// @contact.name API Support
|
|
// @contact.url https://github.com/davidfolch/apocapoc-api
|
|
// @contact.email contact@apocapoc.app
|
|
|
|
// @license.name MIT
|
|
// @license.url https://opensource.org/licenses/MIT
|
|
|
|
// @BasePath /api/v1
|
|
|
|
// @securityDefinitions.apikey BearerAuth
|
|
// @in header
|
|
// @name Authorization
|
|
// @description Type "Bearer" followed by a space and JWT token.
|
|
|
|
func main() {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
}
|
|
|
|
db, err := sqlite.NewDatabase(cfg.DBPath)
|
|
if err != nil {
|
|
log.Fatalf("Failed to connect to database: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
|
|
if err != nil {
|
|
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
|
|
}
|
|
|
|
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
|
|
if err != nil {
|
|
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
|
|
}
|
|
|
|
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
|
passwordHasher := crypto.NewBcryptHasher()
|
|
|
|
var emailService *email.SMTPService
|
|
if cfg.SMTPHost != "" {
|
|
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
|
|
if err != nil {
|
|
log.Fatalf("Invalid SMTP_PORT: %v", err)
|
|
}
|
|
|
|
emailService = email.NewSMTPService(email.SMTPConfig{
|
|
Host: cfg.SMTPHost,
|
|
Port: smtpPort,
|
|
Username: cfg.SMTPUser,
|
|
Password: cfg.SMTPPassword,
|
|
From: cfg.SMTPFrom,
|
|
SupportEmail: cfg.SupportEmail,
|
|
})
|
|
}
|
|
|
|
sendWelcomeEmail := cfg.SendWelcomeEmail == "true"
|
|
|
|
userRepo := sqlite.NewUserRepository(db.Conn())
|
|
habitRepo := sqlite.NewHabitRepository(db.Conn())
|
|
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
|
|
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
|
|
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db.Conn())
|
|
|
|
translator, err := i18n.NewTranslator()
|
|
if err != nil {
|
|
log.Fatalf("Failed to create translator: %v", err)
|
|
}
|
|
|
|
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
|
|
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
|
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
|
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
|
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
|
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, emailService, sendWelcomeEmail)
|
|
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, emailService, cfg.AppURL)
|
|
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, emailService, cfg.AppURL)
|
|
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
|
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
|
|
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
|
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
|
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
|
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
|
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
|
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
|
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
|
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
|
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
|
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
|
|
|
|
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
|
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
|
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
|
|
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
|
|
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
|
|
|
|
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
|
|
|
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
|
log.Printf("Server starting on %s", addr)
|
|
|
|
if err := http.ListenAndServe(addr, router); err != nil {
|
|
log.Fatalf("Server failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func parseJWTExpiry(expiry string) (int, error) {
|
|
expiry = strings.TrimSpace(expiry)
|
|
if strings.HasSuffix(expiry, "h") {
|
|
hours := strings.TrimSuffix(expiry, "h")
|
|
return strconv.Atoi(hours)
|
|
}
|
|
return 0, fmt.Errorf("invalid format, expected format like '24h'")
|
|
}
|
|
|
|
func parseDuration(duration string) (time.Duration, error) {
|
|
duration = strings.TrimSpace(duration)
|
|
if strings.HasSuffix(duration, "m") {
|
|
minutes := strings.TrimSuffix(duration, "m")
|
|
mins, err := strconv.Atoi(minutes)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid minutes value: %w", err)
|
|
}
|
|
return time.Duration(mins) * time.Minute, nil
|
|
}
|
|
if strings.HasSuffix(duration, "h") {
|
|
hours := strings.TrimSuffix(duration, "h")
|
|
hrs, err := strconv.Atoi(hours)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid hours value: %w", err)
|
|
}
|
|
return time.Duration(hrs) * time.Hour, nil
|
|
}
|
|
if strings.HasSuffix(duration, "d") {
|
|
days := strings.TrimSuffix(duration, "d")
|
|
dys, err := strconv.Atoi(days)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid days value: %w", err)
|
|
}
|
|
return time.Duration(dys) * 24 * time.Hour, nil
|
|
}
|
|
return 0, fmt.Errorf("invalid format, expected format like '15m', '24h', or '7d'")
|
|
}
|