mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
initial rebuild
This commit is contained in:
76
internal/api/routes.go
Normal file
76
internal/api/routes.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
httpPprof "net/http/pprof"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/handlers/authn"
|
||||
"github.com/glueops/autoglue/internal/handlers/health"
|
||||
"github.com/glueops/autoglue/internal/handlers/orgs"
|
||||
"github.com/glueops/autoglue/internal/middleware"
|
||||
"github.com/glueops/autoglue/internal/ui"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func RegisterRoutes(r chi.Router) {
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
api.Get("/healthz", health.Check)
|
||||
|
||||
api.Route("/v1", func(v1 chi.Router) {
|
||||
secret := viper.GetString("authentication.jwt_secret")
|
||||
authMW := middleware.AuthMiddleware(secret)
|
||||
|
||||
v1.Route("/auth", func(a chi.Router) {
|
||||
a.Post("/login", authn.Login)
|
||||
a.Post("/register", authn.Register)
|
||||
a.Post("/introspect", authn.Introspect)
|
||||
a.Post("/password/forgot", authn.RequestPasswordReset)
|
||||
a.Post("/password/reset", authn.ConfirmPasswordReset)
|
||||
a.Get("/verify", authn.VerifyEmail)
|
||||
a.Post("/verify/resend", authn.ResendVerification)
|
||||
|
||||
a.Group(func(pr chi.Router) {
|
||||
pr.Use(authMW)
|
||||
pr.Post("/refresh", authn.Refresh)
|
||||
pr.Post("/logout", authn.Logout)
|
||||
pr.Post("/logout_all", authn.LogoutAll)
|
||||
pr.Get("/me", authn.Me)
|
||||
pr.Post("/password/change", authn.ChangePassword)
|
||||
pr.Post("/refresh/rotate", authn.RotateRefreshToken)
|
||||
})
|
||||
})
|
||||
|
||||
v1.Route("/orgs", func(o chi.Router) {
|
||||
o.Use(authMW)
|
||||
o.Post("/", orgs.CreateOrganization)
|
||||
o.Get("/", orgs.ListOrganizations)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/debug/pprof", func(pr chi.Router) {
|
||||
pr.Get("/", httpPprof.Index)
|
||||
pr.Get("/cmdline", httpPprof.Cmdline)
|
||||
pr.Get("/profile", httpPprof.Profile)
|
||||
pr.Get("/symbol", httpPprof.Symbol)
|
||||
pr.Get("/trace", httpPprof.Trace)
|
||||
|
||||
pr.Handle("/allocs", httpPprof.Handler("allocs"))
|
||||
pr.Handle("/block", httpPprof.Handler("block"))
|
||||
pr.Handle("/goroutine", httpPprof.Handler("goroutine"))
|
||||
pr.Handle("/heap", httpPprof.Handler("heap"))
|
||||
pr.Handle("/mutex", httpPprof.Handler("mutex"))
|
||||
pr.Handle("/threadcreate", httpPprof.Handler("threadcreate"))
|
||||
})
|
||||
|
||||
if config.IsUIDev() {
|
||||
if h, err := ui.DevProxy("http://localhost:5173"); err == nil {
|
||||
r.NotFound(h.ServeHTTP)
|
||||
}
|
||||
} else {
|
||||
if h, err := ui.SPAHandler(); err == nil {
|
||||
r.NotFound(h.ServeHTTP)
|
||||
}
|
||||
}
|
||||
}
|
||||
59
internal/api/server.go
Normal file
59
internal/api/server.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/docs"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
httpSwagger "github.com/swaggo/http-swagger/v2"
|
||||
)
|
||||
|
||||
func NewRouter() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Content-Type", "content-type", "Authorization", "authorization", "X-Org-ID", "x-org-id"},
|
||||
AllowCredentials: true,
|
||||
// OptionsPassthrough: false, // default; Chi will auto 200 OPTIONS
|
||||
// MaxAge: 300, // optional
|
||||
}))
|
||||
|
||||
RegisterRoutes(r)
|
||||
|
||||
r.Mount("/swagger", httpSwagger.WrapHandler)
|
||||
r.Get("/swagger/swagger.json", serveSwaggerFromEmbed(docs.SwaggerJSON, "application/json"))
|
||||
r.Get("/swagger/swagger.yaml", serveSwaggerFromEmbed(docs.SwaggerYAML, "application/x-yaml"))
|
||||
return r
|
||||
}
|
||||
|
||||
func NewServer(addr string) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: NewRouter(),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func serveSwaggerFromEmbed(data []byte, contentType string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
6
internal/assets/efs.go
Normal file
6
internal/assets/efs.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed "emails"
|
||||
var EmbeddedFiles embed.FS
|
||||
12
internal/assets/emails/error-notification.tmpl
Normal file
12
internal/assets/emails/error-notification.tmpl
Normal file
@@ -0,0 +1,12 @@
|
||||
{{define "subject"}}Runtime error for {{.BaseURL}}{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Error message: {{.Message}}
|
||||
|
||||
Request method: {{.RequestMethod}}
|
||||
Request URL: {{.RequestURL}}
|
||||
|
||||
Stack trace:
|
||||
|
||||
{{.Trace}}
|
||||
{{end}}
|
||||
24
internal/assets/emails/example.tmpl
Normal file
24
internal/assets/emails/example.tmpl
Normal file
@@ -0,0 +1,24 @@
|
||||
{{define "subject"}}Example subject{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hi {{.Name}},
|
||||
|
||||
This is an example body
|
||||
|
||||
Sent at: {{now}}
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<p>Hi {{.Name}},</p>
|
||||
<p>This is an example body</p>
|
||||
<p>Sent at: {{now}}</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
45
internal/assets/emails/password_reset.tmpl
Normal file
45
internal/assets/emails/password_reset.tmpl
Normal file
@@ -0,0 +1,45 @@
|
||||
{{define "subject"}}Reset your Dragon password{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hi {{.Name}},
|
||||
|
||||
We received a request to reset your password. Use the token below to continue:
|
||||
|
||||
Reset token:
|
||||
{{.Token}}
|
||||
|
||||
{{if .ResetURL}}Or open this link:
|
||||
{{.ResetURL}}{{end}}
|
||||
|
||||
If you didn’t request this, you can safely ignore this email.
|
||||
|
||||
Sent at: {{now}}
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Reset your Dragon password</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Hi {{.Name}},</p>
|
||||
<p>We received a request to reset your password. Use the token below to continue:</p>
|
||||
|
||||
<p><strong>Reset token:</strong><br/>
|
||||
<code style="font-family: monospace; font-size: 14px;">{{.Token}}</code></p>
|
||||
|
||||
{{if .ResetURL}}
|
||||
<p>
|
||||
Or click here:
|
||||
<a href="{{.ResetURL}}">Reset my password</a>
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<p>If you didn’t request this, you can safely ignore this email.</p>
|
||||
<p>Sent at: {{now}}</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
45
internal/assets/emails/verify_account.tmpl
Normal file
45
internal/assets/emails/verify_account.tmpl
Normal file
@@ -0,0 +1,45 @@
|
||||
{{define "subject"}}Verify your Dragon account{{end}}
|
||||
|
||||
{{define "plainBody"}}
|
||||
Hi {{.Name}},
|
||||
|
||||
Welcome to Dragon! Please verify your email to activate your account.
|
||||
|
||||
Verification token:
|
||||
{{.Token}}
|
||||
|
||||
{{if .VerificationURL}}You can also click this link:
|
||||
{{.VerificationURL}}{{end}}
|
||||
|
||||
If you didn’t create an account, you can ignore this message.
|
||||
|
||||
Sent at: {{now}}
|
||||
{{end}}
|
||||
|
||||
{{define "htmlBody"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>Verify your Dragon account</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Hi {{.Name}},</p>
|
||||
<p>Welcome to Dragon! Please verify your email to activate your account.</p>
|
||||
|
||||
<p><strong>Verification token:</strong><br/>
|
||||
<code style="font-family: monospace; font-size: 14px;">{{.Token}}</code></p>
|
||||
|
||||
{{if .VerificationURL}}
|
||||
<p>
|
||||
Or click here:
|
||||
<a href="{{.VerificationURL}}">Verify my email</a>
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<p>If you didn’t create an account, you can ignore this message.</p>
|
||||
<p>Sent at: {{now}}</p>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
109
internal/config/config.go
Normal file
109
internal/config/config.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var File = "config.yaml"
|
||||
var fileKeys = map[string]bool{}
|
||||
|
||||
func Load() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
viper.SetDefault("bind_address", "127.0.0.1")
|
||||
viper.SetDefault("bind_port", "8080")
|
||||
viper.SetDefault("database.dsn", "postgres://user:pass@localhost:5432/db?sslmode=disable")
|
||||
|
||||
viper.SetDefault("ui.dev", false)
|
||||
|
||||
viper.SetDefault("authentication.secret", GenerateSecureSecret())
|
||||
|
||||
viper.SetDefault("smtp.enabled", false)
|
||||
viper.SetDefault("smtp.host", "smtp.example.com")
|
||||
viper.SetDefault("smtp.port", 587)
|
||||
viper.SetDefault("smtp.username", "")
|
||||
viper.SetDefault("smtp.password", "")
|
||||
viper.SetDefault("smtp.from", "no-reply@example.com")
|
||||
|
||||
viper.SetDefault("frontend.base_url", "http://localhost:5173")
|
||||
|
||||
viper.SetEnvPrefix("AUTOGLUE")
|
||||
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
||||
viper.SetConfigFile(File)
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
if _, err := os.Stat(File); err == nil {
|
||||
err := viper.ReadInConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read config file: %v", err)
|
||||
}
|
||||
for _, k := range viper.AllKeys() {
|
||||
fileKeys[k] = true
|
||||
}
|
||||
fmt.Println("Loaded config from", File)
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateSecureSecret() string {
|
||||
b := make([]byte, 32)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
panic("unable to generate secure secret")
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func GetAuthSecret() string {
|
||||
return viper.GetString("authentication.secret")
|
||||
}
|
||||
|
||||
func DebugPrintConfig() {
|
||||
all := viper.AllSettings()
|
||||
fmt.Println("Loaded configuration:")
|
||||
for k, v := range all {
|
||||
fmt.Printf("%s: %#v\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
func IsUIDev() bool {
|
||||
return viper.GetBool("ui.dev")
|
||||
}
|
||||
|
||||
func SMTPEnabled() bool {
|
||||
return viper.GetBool("smtp.enabled")
|
||||
}
|
||||
|
||||
func SMTPHost() string {
|
||||
return viper.GetString("smtp.host")
|
||||
}
|
||||
|
||||
func SMTPPort() int {
|
||||
return viper.GetInt("smtp.port")
|
||||
}
|
||||
|
||||
func SMTPUsername() string {
|
||||
return viper.GetString("smtp.username")
|
||||
}
|
||||
|
||||
func SMTPPassword() string {
|
||||
return viper.GetString("smtp.password")
|
||||
}
|
||||
|
||||
func SMTPFrom() string {
|
||||
return viper.GetString("smtp.from")
|
||||
}
|
||||
|
||||
func FrontendBaseURL() string {
|
||||
return viper.GetString("frontend.base_url")
|
||||
}
|
||||
32
internal/ctxutil/context.go
Normal file
32
internal/ctxutil/context.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package ctxutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
keyUserID ctxKey = "user_id"
|
||||
keyOrgID ctxKey = "org_id"
|
||||
)
|
||||
|
||||
func WithUserID(ctx context.Context, id uuid.UUID) context.Context {
|
||||
return context.WithValue(ctx, keyUserID, id)
|
||||
}
|
||||
|
||||
func UserID(ctx context.Context) (uuid.UUID, bool) {
|
||||
v, ok := ctx.Value(keyUserID).(uuid.UUID)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func WithOrgID(ctx context.Context, orgID uuid.UUID) context.Context {
|
||||
return context.WithValue(ctx, keyOrgID, orgID)
|
||||
}
|
||||
|
||||
func OrgID(ctx context.Context) (uuid.UUID, bool) {
|
||||
v, ok := ctx.Value(keyOrgID).(uuid.UUID)
|
||||
return v, ok
|
||||
}
|
||||
42
internal/db/database.go
Normal file
42
internal/db/database.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/spf13/viper"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func Connect() {
|
||||
dsn := viper.GetString("database.dsn")
|
||||
|
||||
if dsn == "" {
|
||||
log.Fatal("DRAGON_DATABASE_DSN is not set")
|
||||
}
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to DB: %v", err)
|
||||
}
|
||||
|
||||
err = DB.AutoMigrate(
|
||||
&models.EmailVerification{},
|
||||
&models.Invitation{},
|
||||
&models.Member{},
|
||||
&models.Organization{},
|
||||
&models.PasswordReset{},
|
||||
&models.RefreshToken{},
|
||||
&models.User{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("auto migration failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("database connected and migrated")
|
||||
}
|
||||
13
internal/db/models/common.go
Normal file
13
internal/db/models/common.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Timestamped struct {
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime;column:updated_at;not null;default:now()" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
21
internal/db/models/email-verification.go
Normal file
21
internal/db/models/email-verification.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type EmailVerification struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
|
||||
Token string `gorm:"type:char(43);uniqueIndex;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
|
||||
Used bool `gorm:"not null;default:false;index" json:"used"`
|
||||
Timestamped
|
||||
}
|
||||
|
||||
func (e EmailVerification) IsActive(now time.Time) bool {
|
||||
return !e.Used && now.Before(e.ExpiresAt)
|
||||
}
|
||||
19
internal/db/models/invitation.go
Normal file
19
internal/db/models/invitation.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Invitation struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Email string `gorm:"type:text;not null"`
|
||||
Role string `gorm:"type:text;default:'member';not null"`
|
||||
Status string `gorm:"type:text;default:'pending';not null"` // pending, accepted, revoked
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
InviterID uuid.UUID `gorm:"type:uuid;not null"`
|
||||
Timestamped
|
||||
}
|
||||
21
internal/db/models/member.go
Normal file
21
internal/db/models/member.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type MemberRole string
|
||||
|
||||
const (
|
||||
MemberRoleAdmin MemberRole = "admin"
|
||||
MemberRoleMember MemberRole = "member"
|
||||
MemberRoleUser MemberRole = "user"
|
||||
)
|
||||
|
||||
type Member struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
|
||||
Role MemberRole `gorm:"not null;default:member" json:"role"` // e.g. admin, member
|
||||
Timestamped
|
||||
}
|
||||
12
internal/db/models/organization.go
Normal file
12
internal/db/models/organization.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Organization struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"unique" json:"slug"`
|
||||
Logo string `json:"logo"`
|
||||
Metadata string `json:"metadata"`
|
||||
Timestamped
|
||||
}
|
||||
21
internal/db/models/password-reset.go
Normal file
21
internal/db/models/password-reset.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordReset struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
|
||||
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
|
||||
Token string `gorm:"type:char(43);uniqueIndex;not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
|
||||
Used bool `gorm:"not null;default:false;index" json:"used"`
|
||||
Timestamped
|
||||
}
|
||||
|
||||
func (p PasswordReset) IsActive(now time.Time) bool {
|
||||
return !p.Used && now.Before(p.ExpiresAt)
|
||||
}
|
||||
15
internal/db/models/refresh-token.go
Normal file
15
internal/db/models/refresh-token.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type RefreshToken struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
UserID uuid.UUID
|
||||
Token string `gorm:"uniqueIndex"`
|
||||
ExpiresAt time.Time
|
||||
Revoked bool
|
||||
}
|
||||
33
internal/db/models/user.go
Normal file
33
internal/db/models/user.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleAdmin Role = "admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name"`
|
||||
Email string `gorm:"uniqueIndex" json:"email"`
|
||||
EmailVerified bool `gorm:"default:false" json:"email_verified"`
|
||||
EmailVerifiedAt time.Time `gorm:"default:null" json:"email_verified_at"`
|
||||
Password string
|
||||
Role Role
|
||||
Timestamped
|
||||
}
|
||||
|
||||
func (r Role) IsValid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
209
internal/funcs/funcs.go
Normal file
209
internal/funcs/funcs.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package funcs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
var printer = message.NewPrinter(language.English)
|
||||
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
// Time functions
|
||||
"now": time.Now,
|
||||
"timeSince": time.Since,
|
||||
"timeUntil": time.Until,
|
||||
"formatTime": formatTime,
|
||||
"approxDuration": approxDuration,
|
||||
|
||||
// String functions
|
||||
"uppercase": strings.ToUpper,
|
||||
"lowercase": strings.ToLower,
|
||||
"pluralize": pluralize,
|
||||
"slugify": slugify,
|
||||
"safeHTML": safeHTML,
|
||||
|
||||
// Slice functions
|
||||
"join": strings.Join,
|
||||
|
||||
// Number functions
|
||||
"incr": incr,
|
||||
"decr": decr,
|
||||
"formatInt": formatInt,
|
||||
"formatFloat": formatFloat,
|
||||
|
||||
// Boolean functions
|
||||
"yesno": yesno,
|
||||
|
||||
// URL functions
|
||||
"urlSetParam": urlSetParam,
|
||||
"urlDelParam": urlDelParam,
|
||||
}
|
||||
|
||||
func formatTime(format string, t time.Time) string {
|
||||
return t.Format(format)
|
||||
}
|
||||
|
||||
func approxDuration(d time.Duration) string {
|
||||
const (
|
||||
day = 24 * time.Hour
|
||||
year = 365 * day
|
||||
)
|
||||
|
||||
formatUnit := func(count int, singular, plural string) string {
|
||||
if count == 1 {
|
||||
return fmt.Sprintf("1 %s", singular)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", count, plural)
|
||||
}
|
||||
|
||||
switch {
|
||||
case d >= year:
|
||||
return formatUnit(int(math.Round(float64(d)/float64(year))), "year", "years")
|
||||
case d >= day:
|
||||
return formatUnit(int(math.Round(float64(d)/float64(day))), "day", "days")
|
||||
case d >= time.Hour:
|
||||
return formatUnit(int(math.Round(d.Hours())), "hour", "hours")
|
||||
case d >= time.Minute:
|
||||
return formatUnit(int(math.Round(d.Minutes())), "minute", "minutes")
|
||||
case d >= time.Second:
|
||||
return formatUnit(int(math.Round(d.Seconds())), "second", "seconds")
|
||||
default:
|
||||
return "less than 1 second"
|
||||
}
|
||||
}
|
||||
|
||||
func pluralize(count any, singular string, plural string) (string, error) {
|
||||
n, err := toInt64(count)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if n == 1 {
|
||||
return singular, nil
|
||||
}
|
||||
|
||||
return plural, nil
|
||||
}
|
||||
|
||||
func slugify(s string) string {
|
||||
var buf bytes.Buffer
|
||||
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r > unicode.MaxASCII:
|
||||
continue
|
||||
case unicode.IsLetter(r):
|
||||
buf.WriteRune(unicode.ToLower(r))
|
||||
case unicode.IsDigit(r), r == '_', r == '-':
|
||||
buf.WriteRune(r)
|
||||
case unicode.IsSpace(r):
|
||||
buf.WriteRune('-')
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func safeHTML(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
}
|
||||
|
||||
func incr(i any) (int64, error) {
|
||||
n, err := toInt64(i)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n++
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func decr(i any) (int64, error) {
|
||||
n, err := toInt64(i)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n--
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func formatInt(i any) (string, error) {
|
||||
n, err := toInt64(i)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return printer.Sprintf("%d", n), nil
|
||||
}
|
||||
|
||||
func formatFloat(f float64, dp int) string {
|
||||
format := "%." + strconv.Itoa(dp) + "f"
|
||||
return printer.Sprintf(format, f)
|
||||
}
|
||||
|
||||
func yesno(b bool) string {
|
||||
if b {
|
||||
return "Yes"
|
||||
}
|
||||
|
||||
return "No"
|
||||
}
|
||||
|
||||
func urlSetParam(u *url.URL, key string, value any) *url.URL {
|
||||
nu := *u
|
||||
values := nu.Query()
|
||||
|
||||
values.Set(key, fmt.Sprintf("%v", value))
|
||||
|
||||
nu.RawQuery = values.Encode()
|
||||
return &nu
|
||||
}
|
||||
|
||||
func urlDelParam(u *url.URL, key string) *url.URL {
|
||||
nu := *u
|
||||
values := nu.Query()
|
||||
|
||||
values.Del(key)
|
||||
|
||||
nu.RawQuery = values.Encode()
|
||||
return &nu
|
||||
}
|
||||
|
||||
func toInt64(i any) (int64, error) {
|
||||
switch v := i.(type) {
|
||||
case int:
|
||||
return int64(v), nil
|
||||
case int8:
|
||||
return int64(v), nil
|
||||
case int16:
|
||||
return int64(v), nil
|
||||
case int32:
|
||||
return int64(v), nil
|
||||
case int64:
|
||||
return v, nil
|
||||
case uint:
|
||||
return int64(v), nil
|
||||
case uint8:
|
||||
return int64(v), nil
|
||||
case uint16:
|
||||
return int64(v), nil
|
||||
case uint32:
|
||||
return int64(v), nil
|
||||
// Note: uint64 not supported due to risk of truncation.
|
||||
case string:
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unable to convert type %T to int", i)
|
||||
}
|
||||
541
internal/handlers/authn/auth.go
Normal file
541
internal/handlers/authn/auth.go
Normal file
@@ -0,0 +1,541 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/glueops/autoglue/internal/middleware"
|
||||
"github.com/glueops/autoglue/internal/response"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Registers a new user and stores credentials
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body RegisterInput true "User registration input"
|
||||
// @Success 201 {string} string "created"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func Register(w http.ResponseWriter, r *http.Request) {
|
||||
var input RegisterInput
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
user := models.User{Email: input.Email, Password: string(hashed), Name: input.Name, Role: "user"}
|
||||
if err := db.DB.Create(&user).Error; err != nil {
|
||||
http.Error(w, "registration failed", 400)
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusCreated, map[string]string{"status": "created"})
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Authenticate and return a token
|
||||
// @Description Authenticates a user and returns a JWT bearer token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body LoginInput true "User login input"
|
||||
// @Success 200 {object} map[string]string "token"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var input LoginInput
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.Where("email = ?", input.Email).First(&user).Error; err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"exp": time.Now().Add(time.Hour * 72).Unix(),
|
||||
}
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessStr, _ := accessToken.SignedString(jwtSecret)
|
||||
|
||||
refreshTokenStr := uuid.NewString()
|
||||
|
||||
_ = db.DB.Create(&models.RefreshToken{
|
||||
UserID: user.ID,
|
||||
Token: refreshTokenStr,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
Revoked: false,
|
||||
}).Error
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{
|
||||
"access_token": accessStr,
|
||||
"refresh_token": refreshTokenStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @Summary Refresh access token
|
||||
// @Description Use a refresh token to obtain a new access token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "refresh_token"
|
||||
// @Success 200 {object} map[string]string "new access token"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&input)
|
||||
|
||||
var token models.RefreshToken
|
||||
if err := db.DB.Where("token = ? AND revoked = false", input.RefreshToken).First(&token).Error; err != nil || token.ExpiresAt.Before(time.Now()) {
|
||||
http.Error(w, "invalid or expired refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": token.UserID,
|
||||
"exp": time.Now().Add(rotatedAccessTTL).Unix(),
|
||||
}
|
||||
newAccess := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
newToken, _ := newAccess.SignedString(jwtSecret)
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{
|
||||
"access_token": newToken,
|
||||
})
|
||||
}
|
||||
|
||||
// Logout godoc
|
||||
// @Summary Logout user
|
||||
// @Description Revoke a refresh token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "refresh_token"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/logout [post]
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.RefreshToken == "" {
|
||||
response.Error(w, http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
db.DB.Model(&models.RefreshToken{}).Where("token = ?", input.RefreshToken).Update("revoked", true)
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// Me godoc
|
||||
// @Summary Get authenticated user info
|
||||
// @Description Returns the authenticated user's profile and auth context
|
||||
// @Tags auth
|
||||
// @Produce json
|
||||
// @Success 200 {object} MeResponse
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/me [get]
|
||||
func Me(w http.ResponseWriter, r *http.Request) {
|
||||
authCtx := middleware.GetAuthContext(r)
|
||||
if authCtx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", authCtx.UserID).Error; err != nil {
|
||||
response.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
out := MeResponse{
|
||||
User: UserDTO{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
EmailVerified: user.EmailVerified,
|
||||
Role: user.Role,
|
||||
CreatedAt: user.CreatedAt, // from Timestamped
|
||||
UpdatedAt: user.UpdatedAt, // from Timestamped
|
||||
},
|
||||
OrgRole: authCtx.OrgRole,
|
||||
}
|
||||
|
||||
if authCtx.OrganizationID != uuid.Nil {
|
||||
s := authCtx.OrganizationID.String()
|
||||
out.OrganizationID = &s
|
||||
}
|
||||
|
||||
if c := authCtx.Claims; c != nil {
|
||||
var exp, iat, nbf int64
|
||||
if c.ExpiresAt != nil {
|
||||
exp = c.ExpiresAt.Time.Unix()
|
||||
}
|
||||
if c.IssuedAt != nil {
|
||||
iat = c.IssuedAt.Time.Unix()
|
||||
}
|
||||
if c.NotBefore != nil {
|
||||
nbf = c.NotBefore.Time.Unix()
|
||||
}
|
||||
|
||||
out.Claims = &AuthClaimsDTO{
|
||||
Orgs: c.Orgs,
|
||||
Roles: c.Roles,
|
||||
Issuer: c.Issuer,
|
||||
Subject: c.Subject,
|
||||
Audience: []string(c.Audience),
|
||||
ExpiresAt: exp,
|
||||
IssuedAt: iat,
|
||||
NotBefore: nbf,
|
||||
}
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// Introspect godoc
|
||||
// @Summary Introspect a token
|
||||
// @Description Returns whether the token is active and basic metadata
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "token"
|
||||
// @Success 200 {object} map[string]any
|
||||
// @Router /api/v1/auth/introspect [post]
|
||||
func Introspect(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Token == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := jwt.Parse(in.Token, func(t *jwt.Token) (any, error) { return jwtSecret, nil })
|
||||
if err == nil && tok.Valid {
|
||||
claims, _ := tok.Claims.(jwt.MapClaims)
|
||||
_ = response.JSON(w, http.StatusOK, map[string]any{
|
||||
"active": true,
|
||||
"type": "access",
|
||||
"sub": claims["sub"],
|
||||
"exp": claims["exp"],
|
||||
"iat": claims["iat"],
|
||||
"nbf": claims["nbf"],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var rt models.RefreshToken
|
||||
if err := db.DB.Where("token = ? AND revoked = false", in.Token).First(&rt).Error; err == nil && rt.ExpiresAt.After(time.Now()) {
|
||||
_ = response.JSON(w, http.StatusOK, map[string]any{
|
||||
"active": true,
|
||||
"type": "refresh",
|
||||
"sub": rt.UserID,
|
||||
"exp": rt.ExpiresAt.Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]any{"active": false})
|
||||
}
|
||||
|
||||
// RequestPasswordReset godoc
|
||||
// @Summary Request password reset
|
||||
// @Description Sends a reset token to the user's email address
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "email"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Router /api/v1/auth/password/forgot [post]
|
||||
func RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Always return 204 to avoid user enumeration.
|
||||
var user models.User
|
||||
if err := db.DB.Where("email = ?", in.Email).First(&user).Error; err == nil {
|
||||
_ = db.DB.Model(&models.PasswordReset{}).
|
||||
Where("user_id = ? AND used = false AND expires_at > ?", user.ID, time.Now()).
|
||||
Update("used", true).Error
|
||||
|
||||
if tok, err := issuePasswordReset(user.ID, user.Email); err == nil {
|
||||
_ = sendEmail(user.Email, "Password reset", fmt.Sprintf("Your password reset token is: %s", tok))
|
||||
err := sendTemplatedEmail(user.Email, "password_reset.tmpl", PasswordResetData{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Token: tok,
|
||||
ResetURL: fmt.Sprintf("%s/auth/reset?token=%s", config.FrontendBaseURL(), tok), // e.g. fmt.Sprintf("%s/reset?token=%s", frontendURL, tok)
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("smtp send error: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset godoc
|
||||
// @Summary Confirm password reset
|
||||
// @Description Resets the password using a valid reset token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "token, new_password"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Router /api/v1/auth/password/reset [post]
|
||||
func ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Token == "" || in.NewPassword == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var pr models.PasswordReset
|
||||
if err := db.DB.Where("token = ? AND used = false", in.Token).First(&pr).Error; err != nil || pr.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", pr.UserID).Error; err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "failed to hash")
|
||||
return
|
||||
}
|
||||
if err := db.DB.Model(&user).Update("password", string(hashed)).Error; err != nil {
|
||||
response.Error(w, http.StatusInternalServerError, "update failed")
|
||||
return
|
||||
}
|
||||
|
||||
_ = db.DB.Model(&models.PasswordReset{}).Where("id = ?", pr.ID).Update("used", true).Error
|
||||
_ = db.DB.Model(&models.RefreshToken{}).Where("user_id = ? AND revoked = false", user.ID).Update("revoked", true).Error
|
||||
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ChangePassword godoc
|
||||
// @Summary Change password
|
||||
// @Description Changes the password for the authenticated user
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "current_password, new_password"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/password/change [post]
|
||||
func ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := middleware.GetAuthContext(r)
|
||||
if ctx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var in struct {
|
||||
Current string `json:"current_password"`
|
||||
New string `json:"new_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Current == "" || in.New == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.First(&user, "id = ?", ctx.UserID).Error; err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(in.Current)); err != nil {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to hash", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := db.DB.Model(&user).Update("password", string(hashed)).Error; err != nil {
|
||||
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional hardening: revoke all refresh tokens after password change
|
||||
// _ = db.DB.Model(&models.RefreshToken{}).Where("user_id = ?", user.ID).Update("revoked", true)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// VerifyEmail godoc
|
||||
// @Summary Verify email address
|
||||
// @Description Verifies the user's email using a token (often from an emailed link)
|
||||
// @Tags auth
|
||||
// @Produce plain
|
||||
// @Param token query string true "verification token"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Failure 400 {string} string "bad request"
|
||||
// @Router /api/v1/auth/verify [get]
|
||||
func VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var ev models.EmailVerification
|
||||
if err := db.DB.Where("token = ? AND used = false", token).First(&ev).Error; err != nil || ev.ExpiresAt.Before(time.Now()) {
|
||||
response.Error(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
|
||||
_ = db.DB.Model(&models.User{}).Where("id = ?", ev.UserID).Updates(map[string]any{
|
||||
"email_verified": true,
|
||||
"email_verified_at": time.Now(),
|
||||
}).Error
|
||||
_ = db.DB.Model(&models.EmailVerification{}).Where("id = ?", ev.ID).Update("used", true).Error
|
||||
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// ResendVerification godoc
|
||||
// @Summary Resend email verification
|
||||
// @Description Sends a new email verification token if needed
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Param body body map[string]string true "email"
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Router /api/v1/auth/verify/resend [post]
|
||||
func ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Email == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.DB.Where("email = ?", in.Email).First(&user).Error; err == nil {
|
||||
_ = db.DB.Model(&models.EmailVerification{}).
|
||||
Where("user_id = ? AND used = false AND expires_at > ?", user.ID, time.Now()).
|
||||
Update("used", true).Error
|
||||
|
||||
if tok, err := issueEmailVerification(user.ID, user.Email); err == nil {
|
||||
_ = sendEmail(user.Email, "Verify your account", fmt.Sprintf("Your verification token is: %s", tok))
|
||||
_ = sendTemplatedEmail(user.Email, "verify_account.tmpl", VerifyEmailData{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
Token: tok,
|
||||
VerificationURL: "", // e.g. fmt.Sprintf("%s/verify?token=%s", frontendURL, tok)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// LogoutAll godoc
|
||||
// @Summary Logout from all sessions
|
||||
// @Description Revokes all active refresh tokens for the authenticated user
|
||||
// @Tags auth
|
||||
// @Produce plain
|
||||
// @Success 204 {string} string "no content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/logout_all [post]
|
||||
func LogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := middleware.GetAuthContext(r)
|
||||
if ctx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
db.DB.Model(&models.RefreshToken{}).Where("user_id = ? AND revoked = false", ctx.UserID).Update("revoked", true)
|
||||
response.NoContent(w)
|
||||
}
|
||||
|
||||
// RotateRefreshToken godoc
|
||||
// @Summary Rotate refresh token
|
||||
// @Description Exchanges a valid refresh token for a new access and refresh token, revoking the old one
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body map[string]string true "refresh_token"
|
||||
// @Success 200 {object} map[string]string "access_token, refresh_token"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/auth/refresh/rotate [post]
|
||||
func RotateRefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.RefreshToken == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var old models.RefreshToken
|
||||
if err := db.DB.Where("token = ? AND revoked = false", in.RefreshToken).First(&old).Error; err != nil || old.ExpiresAt.Before(time.Now()) {
|
||||
http.Error(w, "invalid or expired refresh token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
_ = db.DB.Model(&models.RefreshToken{}).Where("id = ?", old.ID).Update("revoked", true)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": old.UserID,
|
||||
"exp": time.Now().Add(15 * time.Minute).Unix(),
|
||||
}
|
||||
access := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
accessStr, _ := access.SignedString(jwtSecret)
|
||||
|
||||
newRefresh := models.RefreshToken{
|
||||
UserID: old.UserID,
|
||||
Token: uuid.NewString(),
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
Revoked: false,
|
||||
}
|
||||
_ = db.DB.Create(&newRefresh).Error
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{
|
||||
"access_token": accessStr,
|
||||
"refresh_token": newRefresh.Token,
|
||||
})
|
||||
}
|
||||
79
internal/handlers/authn/dto.go
Normal file
79
internal/handlers/authn/dto.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
appsmtp "github.com/glueops/autoglue/internal/smtp"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte(config.GetAuthSecret())
|
||||
var (
|
||||
mailerOnce sync.Once
|
||||
mailer *appsmtp.Mailer
|
||||
mailerErr error
|
||||
)
|
||||
|
||||
const (
|
||||
resetTTL = 1 * time.Hour // password reset token validity
|
||||
verifyTTL = 48 * time.Hour // email verification token validity
|
||||
refreshTTL = 7 * 24 * time.Hour
|
||||
accessTTL = 72 * time.Hour
|
||||
rotatedAccessTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type RegisterInput struct {
|
||||
Email string `json:"email" example:"me@here.com"`
|
||||
Name string `json:"name" example:"My Name"`
|
||||
Password string `json:"password" example:"123456"`
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Email string `json:"email" example:"me@here.com"`
|
||||
Password string `json:"password" example:"123456"`
|
||||
}
|
||||
|
||||
type UserDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Role models.Role `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AuthClaimsDTO struct {
|
||||
Orgs []string `json:"orgs,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Issuer string `json:"iss,omitempty"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Audience []string `json:"aud,omitempty"`
|
||||
ExpiresAt int64 `json:"exp,omitempty"`
|
||||
IssuedAt int64 `json:"iat,omitempty"`
|
||||
NotBefore int64 `json:"nbf,omitempty"`
|
||||
}
|
||||
|
||||
type MeResponse struct {
|
||||
User UserDTO `json:"user_id"`
|
||||
OrganizationID *string `json:"organization_id,omitempty"`
|
||||
OrgRole string `json:"org_role,omitempty"`
|
||||
Claims *AuthClaimsDTO `json:"claims,omitempty"`
|
||||
}
|
||||
|
||||
type VerifyEmailData struct {
|
||||
Name string
|
||||
Email string
|
||||
Token string
|
||||
VerificationURL string
|
||||
}
|
||||
|
||||
type PasswordResetData struct {
|
||||
Name string
|
||||
Email string
|
||||
Token string
|
||||
ResetURL string
|
||||
}
|
||||
92
internal/handlers/authn/funcs.go
Normal file
92
internal/handlers/authn/funcs.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/config"
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
appsmtp "github.com/glueops/autoglue/internal/smtp"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func randomToken(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func issuePasswordReset(userID uuid.UUID, email string) (string, error) {
|
||||
tok, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pr := models.PasswordReset{
|
||||
UserID: userID,
|
||||
Token: tok, // consider storing hash in prod
|
||||
ExpiresAt: time.Now().Add(resetTTL),
|
||||
Used: false,
|
||||
}
|
||||
if err := db.DB.Create(&pr).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func issueEmailVerification(userID uuid.UUID, email string) (string, error) {
|
||||
tok, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ev := models.EmailVerification{
|
||||
ID: uuid.New(),
|
||||
UserID: userID,
|
||||
Token: tok, // consider storing hash in prod
|
||||
ExpiresAt: time.Now().Add(verifyTTL),
|
||||
Used: false,
|
||||
}
|
||||
if err := db.DB.Create(&ev).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func sendEmail(to, subject, body string) error {
|
||||
// integrate with your provider here
|
||||
fmt.Printf("Sending email to: %s\n", to)
|
||||
fmt.Printf("Subject: %s\n", subject)
|
||||
fmt.Printf("Content-Type: text/html; charset=UTF-8\n")
|
||||
fmt.Printf("%s\n", body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMailer() (*appsmtp.Mailer, error) {
|
||||
mailerOnce.Do(func() {
|
||||
if !config.SMTPEnabled() {
|
||||
mailerErr = fmt.Errorf("smtp disabled")
|
||||
return
|
||||
}
|
||||
mailer, mailerErr = appsmtp.NewMailer(
|
||||
config.SMTPHost(),
|
||||
config.SMTPPort(),
|
||||
config.SMTPUsername(),
|
||||
config.SMTPPassword(),
|
||||
config.SMTPFrom(),
|
||||
)
|
||||
})
|
||||
return mailer, mailerErr
|
||||
}
|
||||
|
||||
func sendTemplatedEmail(to string, templateFile string, data any) error {
|
||||
m, err := getMailer()
|
||||
if err != nil {
|
||||
// fail soft if smtp is disabled; return nil so API UX isn't blocked
|
||||
return nil
|
||||
}
|
||||
return m.Send(to, data, templateFile)
|
||||
}
|
||||
19
internal/handlers/health/health.go
Normal file
19
internal/handlers/health/health.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/glueops/autoglue/internal/response"
|
||||
)
|
||||
|
||||
// Check HealthCheck godoc
|
||||
// @Summary Basic health check
|
||||
// @Description Returns a 200 if the service is up
|
||||
// @Tags health
|
||||
// @Accept json
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "ok"
|
||||
// @Router /api/healthz [get]
|
||||
func Check(w http.ResponseWriter, r *http.Request) {
|
||||
_ = response.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
6
internal/handlers/orgs/dto.go
Normal file
6
internal/handlers/orgs/dto.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package orgs
|
||||
|
||||
type OrgInput struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
90
internal/handlers/orgs/orgs.go
Normal file
90
internal/handlers/orgs/orgs.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package orgs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/glueops/autoglue/internal/middleware"
|
||||
"github.com/glueops/autoglue/internal/response"
|
||||
)
|
||||
|
||||
// CreateOrganization godoc
|
||||
// @Summary Create a new organization
|
||||
// @Description Creates a new organization and assigns the authenticated user as an admin member
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param X-Org-ID header string false "Optional organization context (ignored for creation)"
|
||||
// @Param body body OrgInput true "Organization Input"
|
||||
// @Success 200 {object} map[string]string "organization_id"
|
||||
// @Failure 400 {string} string "invalid input"
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Failure 500 {string} string "internal error"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/orgs [post]
|
||||
func CreateOrganization(w http.ResponseWriter, r *http.Request) {
|
||||
authCtx := middleware.GetAuthContext(r)
|
||||
if authCtx == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := authCtx.UserID
|
||||
|
||||
var input OrgInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil || strings.TrimSpace(input.Name) == "" {
|
||||
http.Error(w, "invalid input", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
org := &models.Organization{
|
||||
Name: input.Name,
|
||||
Slug: input.Slug,
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&org).Error; err != nil {
|
||||
http.Error(w, "could not create org", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
member := models.Member{
|
||||
UserID: userID,
|
||||
OrganizationID: org.ID,
|
||||
Role: "admin",
|
||||
}
|
||||
|
||||
if err := db.DB.Create(&member).Error; err != nil {
|
||||
http.Error(w, "could not add member", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusCreated, org)
|
||||
}
|
||||
|
||||
// ListOrganizations godoc
|
||||
// @Summary List organizations for user
|
||||
// @Tags organizations
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.Organization
|
||||
// @Failure 401 {string} string "unauthorized"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/v1/orgs [get]
|
||||
func ListOrganizations(w http.ResponseWriter, r *http.Request) {
|
||||
auth := middleware.GetAuthContext(r)
|
||||
if auth == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var orgs []models.Organization
|
||||
err := db.DB.Joins("JOIN members m ON m.organization_id = organizations.id").
|
||||
Where("m.user_id = ?", auth.UserID).Where("organizations.deleted_at IS NULL").Find(&orgs).Error
|
||||
if err != nil {
|
||||
http.Error(w, "failed to fetch orgs", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_ = response.JSON(w, http.StatusOK, orgs)
|
||||
}
|
||||
85
internal/middleware/auth.go
Normal file
85
internal/middleware/auth.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/glueops/autoglue/internal/db"
|
||||
"github.com/glueops/autoglue/internal/db/models"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type AuthClaims struct {
|
||||
Orgs []string `json:"orgs"`
|
||||
Roles []string `json:"roles"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type AuthContext struct {
|
||||
UserID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OrgRole string // Role in the org
|
||||
Claims *AuthClaims `json:"claims,omitempty" swaggerignore:"true"`
|
||||
}
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
var authContextKey = contextKey{}
|
||||
|
||||
func AuthMiddleware(secret string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims := &AuthClaims{}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(claims.Subject)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid user id", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
authCtx := &AuthContext{
|
||||
UserID: userUUID,
|
||||
Claims: claims,
|
||||
}
|
||||
|
||||
if orgID := r.Header.Get("X-Org-ID"); orgID != "" {
|
||||
orgUUID, _ := uuid.Parse(orgID)
|
||||
|
||||
var member models.Member
|
||||
if err := db.DB.Where("user_id = ? AND organization_id = ?", claims.Subject, orgID).First(&member).Error; err != nil {
|
||||
http.Error(w, "User not a member of the organization", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
authCtx.OrganizationID = orgUUID
|
||||
authCtx.OrgRole = string(member.Role)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), authContextKey, authCtx)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetAuthContext(r *http.Request) *AuthContext {
|
||||
if ac, ok := r.Context().Value(authContextKey).(*AuthContext); ok {
|
||||
return ac
|
||||
}
|
||||
return nil
|
||||
}
|
||||
39
internal/response/json.go
Normal file
39
internal/response/json.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func JSON(w http.ResponseWriter, status int, data any) error {
|
||||
return JSONWithHeaders(w, status, data, nil)
|
||||
}
|
||||
|
||||
func JSONWithHeaders(w http.ResponseWriter, status int, data any, headers http.Header) error {
|
||||
js, err := json.MarshalIndent(data, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
js = append(js, '\n')
|
||||
|
||||
for key, value := range headers {
|
||||
w.Header()[key] = value
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write(js)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func NoContent(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
116
internal/smtp/mailer.go
Normal file
116
internal/smtp/mailer.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package smtp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"github.com/glueops/autoglue/internal/assets"
|
||||
"github.com/glueops/autoglue/internal/funcs"
|
||||
"github.com/wneessen/go-mail"
|
||||
|
||||
htmlTemplate "html/template"
|
||||
textTemplate "text/template"
|
||||
)
|
||||
|
||||
const defaultTimeout = 10 * time.Second
|
||||
|
||||
type Mailer struct {
|
||||
client *mail.Client
|
||||
from string
|
||||
}
|
||||
|
||||
func NewMailer(host string, port int, username, password, from string) (*Mailer, error) {
|
||||
opts := []mail.Option{
|
||||
mail.WithTimeout(defaultTimeout),
|
||||
mail.WithPort(port),
|
||||
// IMPORTANT for Mailpit/local dev: no TLS
|
||||
mail.WithTLSPolicy(mail.NoTLS),
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
opts = append(opts,
|
||||
mail.WithSMTPAuth(mail.SMTPAuthLogin),
|
||||
mail.WithUsername(username),
|
||||
mail.WithPassword(password),
|
||||
)
|
||||
}
|
||||
|
||||
client, err := mail.NewClient(host, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mailer := &Mailer{
|
||||
client: client,
|
||||
from: from,
|
||||
}
|
||||
|
||||
return mailer, nil
|
||||
}
|
||||
|
||||
func (m *Mailer) Send(recipient string, data any, patterns ...string) error {
|
||||
for i := range patterns {
|
||||
patterns[i] = "emails/" + patterns[i]
|
||||
}
|
||||
msg := mail.NewMsg()
|
||||
|
||||
err := msg.To(recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = msg.From(m.from)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ts, err := textTemplate.New("").Funcs(funcs.TemplateFuncs).ParseFS(assets.EmbeddedFiles, patterns...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject := new(bytes.Buffer)
|
||||
err = ts.ExecuteTemplate(subject, "subject", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg.Subject(subject.String())
|
||||
|
||||
plainBody := new(bytes.Buffer)
|
||||
err = ts.ExecuteTemplate(plainBody, "plainBody", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg.SetBodyString(mail.TypeTextPlain, plainBody.String())
|
||||
|
||||
if ts.Lookup("htmlBody") != nil {
|
||||
ts, err := htmlTemplate.New("").Funcs(funcs.TemplateFuncs).ParseFS(assets.EmbeddedFiles, patterns...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
htmlBody := new(bytes.Buffer)
|
||||
err = ts.ExecuteTemplate(htmlBody, "htmlBody", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String())
|
||||
}
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
err = m.client.DialAndSend(msg)
|
||||
|
||||
if nil == err {
|
||||
return nil
|
||||
}
|
||||
|
||||
if i != 3 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
16
internal/ui/devproxy.go
Normal file
16
internal/ui/devproxy.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func DevProxy(target string) (http.Handler, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := httputil.NewSingleHostReverseProxy(u)
|
||||
return p, nil
|
||||
}
|
||||
14
internal/ui/dist/index.html
vendored
Normal file
14
internal/ui/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<script type="module" crossorigin src="/assets/index-DrmAfy-p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Nf4c5zdA.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
1
internal/ui/dist/vite.svg
vendored
Normal file
1
internal/ui/dist/vite.svg
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
106
internal/ui/static.go
Normal file
106
internal/ui/static.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NOTE: Vite outputs to ui/dist with assets in dist/assets.
|
||||
// If you add more nested folders in the future, include them here too.
|
||||
|
||||
//go:embed dist/* dist/assets/*
|
||||
var distFS embed.FS
|
||||
|
||||
// spaFileSystem serves embedded dist/ files with SPA fallback to index.html
|
||||
type spaFileSystem struct {
|
||||
fs fs.FS
|
||||
}
|
||||
|
||||
func (s spaFileSystem) Open(name string) (fs.File, error) {
|
||||
// Normalize, strip leading slash
|
||||
if strings.HasPrefix(name, "/") {
|
||||
name = name[1:]
|
||||
}
|
||||
// Try exact file
|
||||
f, err := s.fs.Open(name)
|
||||
if err == nil {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// If the requested file doesn't exist, fall back to index.html for SPA routes
|
||||
// BUT only if it's not obviously a static asset extension
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case ".js", ".css", ".map", ".json", ".txt", ".ico", ".png", ".jpg", ".jpeg", ".svg", ".webp", ".gif", ".woff", ".woff2":
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
|
||||
return s.fs.Open("index.html")
|
||||
}
|
||||
|
||||
func newDistFS() (fs.FS, error) {
|
||||
return fs.Sub(distFS, "dist")
|
||||
}
|
||||
|
||||
// SPAHandler returns an http.Handler that serves the embedded UI (with caching)
|
||||
func SPAHandler() (http.Handler, error) {
|
||||
sub, err := newDistFS()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wrap with our SPA filesystem and our own file server to control headers.
|
||||
spa := spaFileSystem{fs: sub}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Prevent /api, /swagger, /debug/pprof from being eaten by SPA fallback.
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
r.URL.Path == "/api" ||
|
||||
strings.HasPrefix(r.URL.Path, "/swagger") ||
|
||||
strings.HasPrefix(r.URL.Path, "/debug/pprof") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Open file (or fallback to index.html)
|
||||
filePath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if filePath == "" {
|
||||
filePath = "index.html"
|
||||
}
|
||||
f, err := spa.Open(filePath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Guess content-type by suffix (let Go detect if possible)
|
||||
// Serve with gentle caching: long for assets, short for HTML
|
||||
if strings.HasSuffix(filePath, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
} else {
|
||||
// Vite assets are hashed; safe to cache
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
|
||||
// Serve content
|
||||
http.ServeContent(w, r, filePath, time.Now(), file{f})
|
||||
}), nil
|
||||
}
|
||||
|
||||
// file wraps fs.File to implement io.ReadSeeker if possible (for ServeContent)
|
||||
type file struct{ fs.File }
|
||||
|
||||
func (f file) Seek(offset int64, whence int) (int64, error) {
|
||||
if s, ok := f.File.(io.Seeker); ok {
|
||||
return s.Seek(offset, whence)
|
||||
}
|
||||
// Fallback: not seekable
|
||||
return 0, fs.ErrInvalid
|
||||
}
|
||||
Reference in New Issue
Block a user