initial rebuild

This commit is contained in:
allanice001
2025-09-01 13:34:13 +01:00
commit 95bd9615d1
100 changed files with 12440 additions and 0 deletions

76
internal/api/routes.go Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
package assets
import "embed"
//go:embed "emails"
var EmbeddedFiles embed.FS

View 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}}

View 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}}

View 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 didnt 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 didnt request this, you can safely ignore this email.</p>
<p>Sent at: {{now}}</p>
</body>
</html>
{{end}}

View 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 didnt 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 didnt create an account, you can ignore this message.</p>
<p>Sent at: {{now}}</p>
</body>
</html>
{{end}}

109
internal/config/config.go Normal file
View 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")
}

View 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
View 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")
}

View 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:"-"`
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
View 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)
}

View 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,
})
}

View 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
}

View 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)
}

View 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"})
}

View File

@@ -0,0 +1,6 @@
package orgs
type OrgInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
}

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
}