This commit is contained in:
2026-03-01 22:52:08 +07:00
commit 1244f3e2d5
63 changed files with 6075 additions and 0 deletions

127
internal/handlers/auth.go Normal file
View File

@@ -0,0 +1,127 @@
package handlers
import (
"net/http"
"git.eeqj.de/sneak/webhooker/internal/database"
)
// HandleLoginPage returns a handler for the login page (GET)
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Check if already logged in
sess, err := h.session.Get(r)
if err == nil && h.session.IsAuthenticated(sess) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Render login page
data := map[string]interface{}{
"Error": "",
}
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
}
}
// HandleLoginSubmit handles the login form submission (POST)
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse form data
if err := r.ParseForm(); err != nil {
h.log.Error("failed to parse form", "error", err)
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
// Validate input
if username == "" || password == "" {
data := map[string]interface{}{
"Error": "Username and password are required",
}
w.WriteHeader(http.StatusBadRequest)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
return
}
// Find user in database
var user database.User
if err := h.db.DB().Where("username = ?", username).First(&user).Error; err != nil {
h.log.Debug("user not found", "username", username)
data := map[string]interface{}{
"Error": "Invalid username or password",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
return
}
// Verify password
valid, err := database.VerifyPassword(password, user.Password)
if err != nil {
h.log.Error("failed to verify password", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if !valid {
h.log.Debug("invalid password", "username", username)
data := map[string]interface{}{
"Error": "Invalid username or password",
}
w.WriteHeader(http.StatusUnauthorized)
h.renderTemplate(w, r, []string{"templates/base.html", "templates/login.html"}, data)
return
}
// Create session
sess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Set user in session
h.session.SetUser(sess, user.ID, user.Username)
// Save session
if err := h.session.Save(r, w, sess); err != nil {
h.log.Error("failed to save session", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
h.log.Info("user logged in", "username", username, "user_id", user.ID)
// Redirect to home page
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
// HandleLogout handles user logout
func (h *Handlers) HandleLogout() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := h.session.Get(r)
if err != nil {
h.log.Error("failed to get session", "error", err)
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
// Destroy session
h.session.Destroy(sess)
// Save the destroyed session
if err := h.session.Save(r, w, sess); err != nil {
h.log.Error("failed to save destroyed session", "error", err)
}
// Redirect to login page
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
}
}

View File

@@ -0,0 +1,137 @@
package handlers
import (
"context"
"encoding/json"
"html/template"
"log/slog"
"net/http"
"git.eeqj.de/sneak/webhooker/internal/database"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/healthcheck"
"git.eeqj.de/sneak/webhooker/internal/logger"
"git.eeqj.de/sneak/webhooker/internal/session"
"go.uber.org/fx"
)
// nolint:revive // HandlersParams is a standard fx naming convention
type HandlersParams struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Database *database.Database
Healthcheck *healthcheck.Healthcheck
Session *session.Session
}
type Handlers struct {
params *HandlersParams
log *slog.Logger
hc *healthcheck.Healthcheck
db *database.Database
session *session.Session
}
func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) {
s := new(Handlers)
s.params = &params
s.log = params.Logger.Get()
s.hc = params.Healthcheck
s.db = params.Database
s.session = params.Session
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
// FIXME compile some templates here or something
return nil
},
})
return s, nil
}
//nolint:unparam // r parameter will be used in the future for request context
func (s *Handlers) respondJSON(w http.ResponseWriter, r *http.Request, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if data != nil {
err := json.NewEncoder(w).Encode(data)
if err != nil {
s.log.Error("json encode error", "error", err)
}
}
}
//nolint:unparam,unused // will be used for handling JSON requests
func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v)
}
// TemplateData represents the common data passed to templates
type TemplateData struct {
User *UserInfo
Version string
UserCount int64
Uptime string
}
// UserInfo represents user information for templates
type UserInfo struct {
ID string
Username string
}
// renderTemplate renders a template with common data
func (s *Handlers) renderTemplate(w http.ResponseWriter, r *http.Request, templateFiles []string, data interface{}) {
// Always include the common templates
allTemplates := []string{"templates/htmlheader.html", "templates/navbar.html"}
allTemplates = append(allTemplates, templateFiles...)
// Parse templates
tmpl, err := template.ParseFiles(allTemplates...)
if err != nil {
s.log.Error("failed to parse template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get user from session if available
var userInfo *UserInfo
sess, err := s.session.Get(r)
if err == nil && s.session.IsAuthenticated(sess) {
if username, ok := s.session.GetUsername(sess); ok {
if userID, ok := s.session.GetUserID(sess); ok {
userInfo = &UserInfo{
ID: userID,
Username: username,
}
}
}
}
// Wrap data with base template data
type templateDataWrapper struct {
User *UserInfo
Data interface{}
}
wrapper := templateDataWrapper{
User: userInfo,
Data: data,
}
// If data is a map, merge user info into it
if m, ok := data.(map[string]interface{}); ok {
m["User"] = userInfo
if err := tmpl.Execute(w, m); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Otherwise use wrapper
if err := tmpl.Execute(w, wrapper); err != nil {
s.log.Error("failed to execute template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,130 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"git.eeqj.de/sneak/webhooker/internal/config"
"git.eeqj.de/sneak/webhooker/internal/database"
"git.eeqj.de/sneak/webhooker/internal/globals"
"git.eeqj.de/sneak/webhooker/internal/healthcheck"
"git.eeqj.de/sneak/webhooker/internal/logger"
"git.eeqj.de/sneak/webhooker/internal/session"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
func TestHandleIndex(t *testing.T) {
var h *Handlers
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{
// This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!"
SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
}
},
func() *database.Database {
// Mock database with a mock DB method
db := &database.Database{}
return db
},
healthcheck.New,
session.New,
New,
),
fx.Populate(&h),
)
app.RequireStart()
defer app.RequireStop()
// Since we can't test actual template rendering without templates,
// let's test that the handler is created and doesn't panic
handler := h.HandleIndex()
assert.NotNil(t, handler)
}
func TestRenderTemplate(t *testing.T) {
var h *Handlers
app := fxtest.New(
t,
fx.Provide(
globals.New,
logger.New,
func() *config.Config {
return &config.Config{
// This is a base64 encoded 32-byte key: "test-session-key-32-bytes-long!!"
SessionKey: "dGVzdC1zZXNzaW9uLWtleS0zMi1ieXRlcy1sb25nISE=",
}
},
func() *database.Database {
// Mock database
return &database.Database{}
},
healthcheck.New,
session.New,
New,
),
fx.Populate(&h),
)
app.RequireStart()
defer app.RequireStop()
t.Run("handles missing templates gracefully", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
data := map[string]interface{}{
"Version": "1.0.0",
}
// When templates don't exist, renderTemplate should return an error
h.renderTemplate(w, req, []string{"nonexistent.html"}, data)
// Should return internal server error when template parsing fails
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
}
func TestFormatUptime(t *testing.T) {
tests := []struct {
name string
duration string
expected string
}{
{
name: "minutes only",
duration: "45m",
expected: "45m",
},
{
name: "hours and minutes",
duration: "2h30m",
expected: "2h 30m",
},
{
name: "days, hours and minutes",
duration: "25h45m",
expected: "1d 1h 45m",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, err := time.ParseDuration(tt.duration)
require.NoError(t, err)
result := formatUptime(d)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -0,0 +1,12 @@
package handlers
import (
"net/http"
)
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
resp := s.hc.Healthcheck()
s.respondJSON(w, req, resp, 200)
}
}

View File

@@ -0,0 +1,54 @@
package handlers
import (
"fmt"
"net/http"
"time"
"git.eeqj.de/sneak/webhooker/internal/database"
)
type IndexResponse struct {
Message string `json:"message"`
Version string `json:"version"`
}
func (s *Handlers) HandleIndex() http.HandlerFunc {
// Calculate server start time
startTime := time.Now()
return func(w http.ResponseWriter, req *http.Request) {
// Calculate uptime
uptime := time.Since(startTime)
uptimeStr := formatUptime(uptime)
// Get user count from database
var userCount int64
s.db.DB().Model(&database.User{}).Count(&userCount)
// Prepare template data
data := map[string]interface{}{
"Version": s.params.Globals.Version,
"Uptime": uptimeStr,
"UserCount": userCount,
}
// Render the template
s.renderTemplate(w, req, []string{"templates/base.html", "templates/index.html"}, data)
}
}
// formatUptime formats a duration into a human-readable string
func formatUptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
)
// HandleProfile returns a handler for the user profile page
func (h *Handlers) HandleProfile() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get username from URL
requestedUsername := chi.URLParam(r, "username")
if requestedUsername == "" {
http.NotFound(w, r)
return
}
// Get session
sess, err := h.session.Get(r)
if err != nil || !h.session.IsAuthenticated(sess) {
// Redirect to login if not authenticated
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
return
}
// Get user info from session
sessionUsername, ok := h.session.GetUsername(sess)
if !ok {
h.log.Error("authenticated session missing username")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
sessionUserID, ok := h.session.GetUserID(sess)
if !ok {
h.log.Error("authenticated session missing user ID")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// For now, only allow users to view their own profile
if requestedUsername != sessionUsername {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Prepare data for template
data := map[string]interface{}{
"User": &UserInfo{
ID: sessionUserID,
Username: sessionUsername,
},
}
// Render the profile page
h.renderTemplate(w, r, []string{"templates/base.html", "templates/profile.html"}, data)
}
}

View File

@@ -0,0 +1,69 @@
package handlers
import (
"net/http"
)
// HandleSourceList shows a list of user's webhook sources
func (h *Handlers) HandleSourceList() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source list page
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceCreate shows the form to create a new webhook source
func (h *Handlers) HandleSourceCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source creation form
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceCreateSubmit handles the source creation form submission
func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source creation logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceDetail shows details for a specific webhook source
func (h *Handlers) HandleSourceDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source detail page
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceEdit shows the form to edit a webhook source
func (h *Handlers) HandleSourceEdit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source edit form
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceEditSubmit handles the source edit form submission
func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source update logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceDelete handles webhook source deletion
func (h *Handlers) HandleSourceDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source deletion logic
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}
// HandleSourceLogs shows the request/response logs for a webhook source
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: Implement source logs page
http.Error(w, "Not implemented", http.StatusNotImplemented)
}
}

View File

@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
)
// HandleWebhook handles incoming webhook requests
func (h *Handlers) HandleWebhook() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get webhook UUID from URL
webhookUUID := chi.URLParam(r, "uuid")
if webhookUUID == "" {
http.NotFound(w, r)
return
}
// Log the incoming webhook request
h.log.Info("webhook request received",
"uuid", webhookUUID,
"method", r.Method,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
// Only POST methods are allowed for webhooks
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// TODO: Implement webhook handling logic
// For now, return "unimplemented" for all webhook POST requests
w.WriteHeader(http.StatusNotFound)
_, err := w.Write([]byte("unimplemented"))
if err != nil {
h.log.Error("failed to write response", "error", err)
}
}
}