feat: production release with React SPA, file attachments, and deploy tooling
Backend: - Add file_handlers.go: presigned upload/download for item attachments - Add item_files.go: item file and thumbnail DB operations - Add migration 011: item_files table and thumbnail_key column - Update items/projects/relationships DB with extended field support - Update routes: React SPA serving from web/dist, file upload endpoints - Update auth handlers and middleware for cookie + bearer token auth - Remove Go HTML templates (replaced by React SPA) - Update storage client for presigned URL generation Frontend: - Add TagInput component for tag/keyword entry - Add SVG assets for Silo branding and UI icons - Update API client and types for file uploads, auth, extended fields - Update AuthContext for session-based auth flow - Update LoginPage, ProjectsPage, SchemasPage, SettingsPage - Fix tsconfig.node.json Deployment: - Update config.prod.yaml: single-binary SPA layout at /opt/silo - Update silod.service: ReadOnlyPaths for /opt/silo - Add scripts/deploy.sh: build, package, ship, migrate, start - Update docker-compose.yaml and Dockerfile - Add frontend-spec.md design document
This commit is contained in:
@@ -5,42 +5,24 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/justinas/nosurf"
|
||||
"github.com/kindredsystems/silo/internal/auth"
|
||||
)
|
||||
|
||||
// loginPageData holds template data for the login page.
|
||||
type loginPageData struct {
|
||||
Error string
|
||||
Username string
|
||||
Next string
|
||||
CSRFToken string
|
||||
OIDCEnabled bool
|
||||
// HandleAuthConfig returns public auth configuration for the login page.
|
||||
func (s *Server) HandleAuthConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"oidc_enabled": s.oidc != nil,
|
||||
"local_enabled": s.authConfig != nil && s.authConfig.Local.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleLoginPage renders the login page.
|
||||
// HandleLoginPage redirects to the SPA (React handles the login UI).
|
||||
func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
// If auth is disabled, redirect to home
|
||||
if s.authConfig == nil || !s.authConfig.Enabled {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// If already logged in, redirect to home
|
||||
if s.sessions != nil {
|
||||
userID := s.sessions.GetString(r.Context(), "user_id")
|
||||
if userID != "" {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.renderLogin(w, r, "")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleLogin processes the login form submission.
|
||||
@@ -54,21 +36,21 @@ func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.FormValue("password")
|
||||
|
||||
if username == "" || password == "" {
|
||||
s.renderLogin(w, r, "Username and password are required")
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "Username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.auth.Authenticate(r.Context(), username, password)
|
||||
if err != nil {
|
||||
s.logger.Warn().Str("username", username).Err(err).Msg("login failed")
|
||||
s.renderLogin(w, r, "Invalid username or password")
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to renew session token")
|
||||
s.renderLogin(w, r, "Internal error, please try again")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Internal error, please try again")
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", user.ID)
|
||||
@@ -183,8 +165,8 @@ func (s *Server) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// createTokenRequest is the request body for token creation.
|
||||
type createTokenRequest struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||
}
|
||||
|
||||
// HandleCreateToken creates a new API token (JSON API).
|
||||
@@ -283,87 +265,6 @@ func (s *Server) HandleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleSettingsPage renders the settings page.
|
||||
func (s *Server) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
tokens, _ := s.auth.ListTokens(r.Context(), user.ID)
|
||||
|
||||
// Retrieve one-time new token from session (if just created)
|
||||
var newToken string
|
||||
if s.sessions != nil {
|
||||
newToken = s.sessions.PopString(r.Context(), "new_token")
|
||||
}
|
||||
|
||||
data := PageData{
|
||||
Title: "Settings",
|
||||
Page: "settings",
|
||||
User: user,
|
||||
CSRFToken: nosurf.Token(r),
|
||||
Data: map[string]any{
|
||||
"tokens": tokens,
|
||||
"new_token": newToken,
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.webHandler.templates.ExecuteTemplate(w, "base.html", data); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to render settings page")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleCreateTokenWeb creates a token via the web form.
|
||||
func (s *Server) HandleCreateTokenWeb(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
name = "Unnamed token"
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if daysStr := r.FormValue("expires_in_days"); daysStr != "" {
|
||||
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 {
|
||||
t := time.Now().AddDate(0, 0, d)
|
||||
expiresAt = &t
|
||||
}
|
||||
}
|
||||
|
||||
rawToken, _, err := s.auth.GenerateToken(r.Context(), user.ID, name, nil, expiresAt)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to generate token")
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the raw token in session for one-time display
|
||||
s.sessions.Put(r.Context(), "new_token", rawToken)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleRevokeTokenWeb revokes a token via the web form.
|
||||
func (s *Server) HandleRevokeTokenWeb(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.UserFromContext(r.Context())
|
||||
tokenID := chi.URLParam(r, "id")
|
||||
_ = s.auth.RevokeToken(r.Context(), user.ID, tokenID)
|
||||
http.Redirect(w, r, "/settings", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
data := loginPageData{
|
||||
Error: errMsg,
|
||||
Username: r.FormValue("username"),
|
||||
Next: r.URL.Query().Get("next"),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
OIDCEnabled: s.oidc != nil,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.webHandler.templates.ExecuteTemplate(w, "login.html", data); err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to render login page")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func generateRandomState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user