8e08c73f60
Full-stack Go monolith with embedded React frontend for orchestrating rsync-over-SSH file synchronization with Wake-on-LAN support. Features: - JWT auth (HS256) with bcrypt password hashing - CRUD for machines (with WoL config) and sync_pairs - Ed25519 SSH key generation and known_hosts management - WoL magic packet sender + TCP-connect waiter with backoff - Sync engine: rsync subprocess, per-pair job queue, progress parsing - Homebrew cron parser for scheduled syncs - SSE stream for live job status (queued/waking_up/running/success/failed) - React+TS+Vite+Tailwind SPA embedded via embed.FS - Debian packaging with systemd unit, postinst/prerm/postrm Tech stack: - Go 1.22+ (CGO_ENABLED=0, pure SQLite via modernc.org/sqlite) - chi router for HTTP API - TypeScript + React 18 + Tailwind CSS frontend - Cross-compiled to Linux amd64 for Proxmox LXC deployment Tests: wol (MAC parsing, magic packet), syncengine/queue, scheduler/cron
108 lines
2.5 KiB
Go
108 lines
2.5 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/syncserver/internal/auth"
|
|
)
|
|
|
|
type AuthHandler struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewAuthHandler(db *sql.DB) *AuthHandler {
|
|
return &AuthHandler{db: db}
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type LoginResponse struct {
|
|
User UserResponse `json:"user"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
type UserResponse struct {
|
|
ID int64 `json:"id"`
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
var req LoginRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
row := h.db.QueryRow(
|
|
"SELECT id, username, password_hash, role FROM users WHERE username = ?",
|
|
req.Username,
|
|
)
|
|
var u struct {
|
|
ID int64
|
|
Username string
|
|
PasswordHash string
|
|
Role string
|
|
}
|
|
if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role); err != nil {
|
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if !auth.VerifyPassword([]byte(u.PasswordHash), req.Password) {
|
|
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
jwtMgr := auth.GetJWTManager()
|
|
if jwtMgr == nil {
|
|
http.Error(w, `{"error":"server misconfigured"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
token, expiresAt, err := jwtMgr.Generate(u.ID, u.Username, u.Role)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to generate token"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
auth.SetAuthCookie(w, token, expiresAt)
|
|
|
|
resp := LoginResponse{
|
|
User: UserResponse{
|
|
ID: u.ID,
|
|
Username: u.Username,
|
|
Role: u.Role,
|
|
},
|
|
ExpiresAt: expiresAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
auth.ClearAuthCookie(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
|
claims := auth.GetClaims(r.Context())
|
|
if claims == nil {
|
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
resp := UserResponse{
|
|
ID: claims.UserID,
|
|
Username: claims.Username,
|
|
Role: claims.Role,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]interface{}{"user": resp})
|
|
}
|