mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
## Alert and Confirm Modal System for Tsunami This PR implements a complete modal system for the tsunami app framework as specified in the requirements. ### Implementation Summary **Backend (Go) - 574 lines changed across 11 files:** 1. **Type Definitions** (`rpctypes/protocoltypes.go`): - `ModalConfig`: Configuration for modal display with icon, title, text, and button labels - `ModalResult`: Result structure containing modal ID and confirmation status 2. **Client State Management** (`engine/clientimpl.go`): - Added `ModalState` to track open modals with result channels - `OpenModals` map to track all currently open modals - `ShowModal()`: Sends SSE event to display modal and returns result channel - `CloseModal()`: Processes modal result from frontend - `CloseAllModals()`: Automatically cancels all modals when frontend sends Resync flag (page refresh) 3. **API Endpoint** (`engine/serverhandlers.go`): - `/api/modalresult` POST endpoint to receive modal results from frontend - Validates and processes `ModalResult` JSON payload - Closes all modals on Resync (page refresh) before processing events 4. **User-Facing Hooks** (`app/hooks.go`): - `UseAlertModal()`: Returns (isOpen, triggerAlert) for alert modals - `UseConfirmModal()`: Returns (isOpen, triggerConfirm) for confirm modals - Both hooks manage local state and handle async modal lifecycle **Frontend (TypeScript/React):** 1. **Type Definitions** (`types/vdom.d.ts`): - Added `ModalConfig` and `ModalResult` TypeScript types 2. **Modal Components** (`element/modals.tsx`): - `AlertModal`: Dark-mode styled alert with icon, title, text, and OK button - `ConfirmModal`: Dark-mode styled confirm with icon, title, text, OK and Cancel buttons - Both support keyboard (ESC) and backdrop click dismissal - Fully accessible with focus management 3. **Model Integration** (`model/tsunami-model.tsx`): - Added `currentModal` atom to track displayed modal - SSE event handler for `showmodal` events - `sendModalResult()`: Sends result to `/api/modalresult` and clears modal 4. **UI Integration** (`vdom.tsx`): - Integrated modal display in `VDomView` component - Conditionally renders alert or confirm modal based on type **Demo Application** (`demo/modaltest/`): - Comprehensive demonstration of modal functionality - Shows 4 different modal configurations: - Alert with icon - Simple alert with custom button text - Confirm modal - Delete confirmation with custom buttons - Displays modal state and results in real-time ### Key Features ✅ **SSE-Based Modal Display**: Modals are pushed from backend to frontend via SSE ✅ **API-Based Result Handling**: Results sent back via `/api/modalresult` endpoint ✅ **Automatic Cleanup**: All open modals auto-cancel on page refresh (when Resync flag is set) ✅ **Type-Safe Hooks**: Full TypeScript and Go type safety ✅ **Dark Mode UI**: Components styled for Wave Terminal's dark theme ✅ **Accessibility**: Keyboard navigation, ESC to dismiss, backdrop click support ✅ **Zero Security Issues**: Passed CodeQL security analysis ✅ **Zero Code Review Issues**: Clean implementation following best practices ### Testing - ✅ Go code compiles without errors - ✅ TypeScript/React builds without errors - ✅ All existing tests pass - ✅ Demo app created and compiles successfully - ✅ CodeQL security scan: 0 vulnerabilities - ✅ Code review: 0 issues ### Security Summary No security vulnerabilities were introduced. All modal operations are properly scoped to the client's SSE connection, and modal IDs are generated server-side to prevent tampering. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
568 lines
15 KiB
Go
568 lines
15 KiB
Go
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/wavetermdev/waveterm/tsunami/rpctypes"
|
|
"github.com/wavetermdev/waveterm/tsunami/util"
|
|
)
|
|
|
|
const SSEKeepAliveDuration = 5 * time.Second
|
|
|
|
func init() {
|
|
// Add explicit mapping for .json files
|
|
mime.AddExtensionType(".json", "application/json")
|
|
}
|
|
|
|
type handlerOpts struct {
|
|
AssetsFS fs.FS
|
|
StaticFS fs.FS
|
|
ManifestFile []byte
|
|
}
|
|
|
|
type httpHandlers struct {
|
|
Client *ClientImpl
|
|
renderLock sync.Mutex
|
|
}
|
|
|
|
func newHTTPHandlers(client *ClientImpl) *httpHandlers {
|
|
return &httpHandlers{
|
|
Client: client,
|
|
}
|
|
}
|
|
|
|
func setNoCacheHeaders(w http.ResponseWriter) {
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
w.Header().Set("Expires", "0")
|
|
}
|
|
|
|
func (h *httpHandlers) registerHandlers(mux *http.ServeMux, opts handlerOpts) {
|
|
mux.HandleFunc("/api/render", h.handleRender)
|
|
mux.HandleFunc("/api/updates", h.handleSSE)
|
|
mux.HandleFunc("/api/data", h.handleData)
|
|
mux.HandleFunc("/api/config", h.handleConfig)
|
|
mux.HandleFunc("/api/schemas", h.handleSchemas)
|
|
mux.HandleFunc("/api/manifest", h.handleManifest(opts.ManifestFile))
|
|
mux.HandleFunc("/api/modalresult", h.handleModalResult)
|
|
mux.HandleFunc("/dyn/", h.handleDynContent)
|
|
|
|
// Add handler for static files at /static/ path
|
|
if opts.StaticFS != nil {
|
|
mux.HandleFunc("/static/", h.handleStaticPathFiles(opts.StaticFS))
|
|
}
|
|
|
|
// Add fallback handler for embedded static files in production mode
|
|
if opts.AssetsFS != nil {
|
|
mux.HandleFunc("/", h.handleStaticFiles(opts.AssetsFS))
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleRender(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleRender", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var feUpdate rpctypes.VDomFrontendUpdate
|
|
if err := json.Unmarshal(body, &feUpdate); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if feUpdate.ForceTakeover {
|
|
h.Client.clientTakeover(feUpdate.ClientId)
|
|
}
|
|
|
|
if err := h.Client.checkClientId(feUpdate.ClientId); err != nil {
|
|
http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
startTime := time.Now()
|
|
update, err := h.processFrontendUpdate(&feUpdate)
|
|
duration := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("render error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if update == nil {
|
|
w.WriteHeader(http.StatusOK)
|
|
if os.Getenv("TSUNAMI_DEBUG") != "" {
|
|
log.Printf("render %4s %4dms %4dk %s", "none", duration.Milliseconds(), 0, feUpdate.Reason)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Encode to bytes first to calculate size
|
|
responseBytes, err := json.Marshal(update)
|
|
if err != nil {
|
|
log.Printf("failed to encode response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
updateSizeKB := len(responseBytes) / 1024
|
|
renderType := "inc"
|
|
if update.FullUpdate {
|
|
renderType = "full"
|
|
}
|
|
if os.Getenv("TSUNAMI_DEBUG") != "" {
|
|
log.Printf("render %4s %4dms %4dk %s", renderType, duration.Milliseconds(), updateSizeKB, feUpdate.Reason)
|
|
}
|
|
|
|
if _, err := w.Write(responseBytes); err != nil {
|
|
log.Printf("failed to write response: %v", err)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) processFrontendUpdate(feUpdate *rpctypes.VDomFrontendUpdate) (*rpctypes.VDomBackendUpdate, error) {
|
|
h.renderLock.Lock()
|
|
defer h.renderLock.Unlock()
|
|
|
|
if feUpdate.Dispose {
|
|
log.Printf("got dispose from frontend\n")
|
|
h.Client.doShutdown("got dispose from frontend")
|
|
return nil, nil
|
|
}
|
|
|
|
if h.Client.GetIsDone() {
|
|
return nil, nil
|
|
}
|
|
|
|
h.Client.Root.RenderTs = feUpdate.Ts
|
|
|
|
// Close all open modals on resync (e.g., page refresh)
|
|
if feUpdate.Resync {
|
|
h.Client.CloseAllModals()
|
|
}
|
|
|
|
// run events
|
|
h.Client.RunEvents(feUpdate.Events)
|
|
// update refs
|
|
for _, ref := range feUpdate.RefUpdates {
|
|
h.Client.Root.UpdateRef(ref)
|
|
}
|
|
|
|
var update *rpctypes.VDomBackendUpdate
|
|
var renderErr error
|
|
|
|
if feUpdate.Resync || true {
|
|
update, renderErr = h.Client.fullRender()
|
|
} else {
|
|
update, renderErr = h.Client.incrementalRender()
|
|
}
|
|
|
|
if renderErr != nil {
|
|
return nil, renderErr
|
|
}
|
|
|
|
update.CreateTransferElems()
|
|
return update, nil
|
|
}
|
|
|
|
func (h *httpHandlers) handleData(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleData", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
result := h.Client.Root.GetDataMap()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("failed to encode data response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleConfig", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.handleConfigGet(w, r)
|
|
case http.MethodPost:
|
|
h.handleConfigPost(w, r)
|
|
default:
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleConfigGet(w http.ResponseWriter, _ *http.Request) {
|
|
result := h.Client.Root.GetConfigMap()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("failed to encode config response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleConfigPost(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var configData map[string]any
|
|
if err := json.Unmarshal(body, &configData); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var failedKeys []string
|
|
for key, value := range configData {
|
|
atomName := "$config." + key
|
|
if err := h.Client.Root.SetAtomVal(atomName, value); err != nil {
|
|
failedKeys = append(failedKeys, key)
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
var response map[string]any
|
|
if len(failedKeys) > 0 {
|
|
response = map[string]any{
|
|
"error": fmt.Sprintf("Failed to update keys: %s", strings.Join(failedKeys, ", ")),
|
|
}
|
|
} else {
|
|
response = map[string]any{
|
|
"success": true,
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (h *httpHandlers) handleSchemas(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleSchemas", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
configSchema := GenerateConfigSchema(h.Client.Root)
|
|
dataSchema := GenerateDataSchema(h.Client.Root)
|
|
|
|
result := map[string]any{
|
|
"config": configSchema,
|
|
"data": dataSchema,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if err := json.NewEncoder(w).Encode(result); err != nil {
|
|
log.Printf("failed to encode schemas response: %v", err)
|
|
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleModalResult(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleModalResult", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var result rpctypes.ModalResult
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
http.Error(w, fmt.Sprintf("failed to parse JSON: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
h.Client.CloseModal(result.ModalId, result.Confirm)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]any{"success": true})
|
|
}
|
|
|
|
func (h *httpHandlers) handleDynContent(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleDynContent", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
// Strip /assets prefix and update the request URL
|
|
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/dyn")
|
|
if r.URL.Path == "" {
|
|
r.URL.Path = "/"
|
|
}
|
|
|
|
h.Client.UrlHandlerMux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (h *httpHandlers) handleSSE(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleSSE", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
clientId := r.URL.Query().Get("clientId")
|
|
if err := h.Client.checkClientId(clientId); err != nil {
|
|
http.Error(w, fmt.Sprintf("client id error: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Generate unique connection ID for this SSE connection
|
|
connectionId := fmt.Sprintf("%s-%d", clientId, time.Now().UnixNano())
|
|
|
|
// Register SSE channel for this connection
|
|
eventCh := h.Client.RegisterSSEChannel(connectionId)
|
|
defer h.Client.UnregisterSSEChannel(connectionId)
|
|
|
|
// Set SSE headers
|
|
setNoCacheHeaders(w)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// Use ResponseController for better flushing control
|
|
rc := http.NewResponseController(w)
|
|
if err := rc.Flush(); err != nil {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Create a ticker for keepalive packets
|
|
keepaliveTicker := time.NewTicker(SSEKeepAliveDuration)
|
|
defer keepaliveTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-keepaliveTicker.C:
|
|
// Send keepalive comment
|
|
fmt.Fprintf(w, ": keepalive\n\n")
|
|
rc.Flush()
|
|
case event := <-eventCh:
|
|
if event.Event == "" {
|
|
break
|
|
}
|
|
fmt.Fprintf(w, "event: %s\n", event.Event)
|
|
fmt.Fprintf(w, "data: %s\n", string(event.Data))
|
|
fmt.Fprintf(w, "\n")
|
|
rc.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// serveFileDirectly serves a file directly from an embed.FS to avoid redirect loops
|
|
// when serving directory paths that end with "/"
|
|
func serveFileDirectly(w http.ResponseWriter, r *http.Request, embeddedFS fs.FS, requestPath, fileName string) bool {
|
|
if !strings.HasSuffix(requestPath, "/") {
|
|
return false
|
|
}
|
|
|
|
// Try to serve the specified file from that directory
|
|
var filePath string
|
|
if requestPath == "/" {
|
|
filePath = fileName
|
|
} else {
|
|
filePath = strings.TrimPrefix(requestPath, "/") + fileName
|
|
}
|
|
|
|
file, err := embeddedFS.Open(filePath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer file.Close()
|
|
|
|
// Get file info for modification time
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Serve the file directly with proper mod time
|
|
http.ServeContent(w, r, fileName, fileInfo.ModTime(), file.(io.ReadSeeker))
|
|
return true
|
|
}
|
|
|
|
func (h *httpHandlers) handleStaticFiles(embeddedFS fs.FS) http.HandlerFunc {
|
|
fileServer := http.FileServer(http.FS(embeddedFS))
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleStaticFiles", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
// Skip if this is an API, files, or static request (already handled by other handlers)
|
|
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/files/") || strings.HasPrefix(r.URL.Path, "/static/") {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Handle any path ending with "/" to avoid redirect loops
|
|
if serveFileDirectly(w, r, embeddedFS, r.URL.Path, "index.html") {
|
|
return
|
|
}
|
|
|
|
// For other files, check if they exist before serving
|
|
filePath := strings.TrimPrefix(r.URL.Path, "/")
|
|
_, err := embeddedFS.Open(filePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Serve the file using the file server
|
|
fileServer.ServeHTTP(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleManifest(manifestFileBytes []byte) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleManifest", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
setNoCacheHeaders(w)
|
|
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
if manifestFileBytes == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(manifestFileBytes)
|
|
}
|
|
}
|
|
|
|
func (h *httpHandlers) handleStaticPathFiles(staticFS fs.FS) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
panicErr := util.PanicHandler("handleStaticPathFiles", recover())
|
|
if panicErr != nil {
|
|
http.Error(w, fmt.Sprintf("internal server error: %v", panicErr), http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
|
|
// Strip /static/ prefix from the path
|
|
filePath := strings.TrimPrefix(r.URL.Path, "/static/")
|
|
if filePath == "" {
|
|
// Handle requests to "/static/" directly
|
|
if serveFileDirectly(w, r, staticFS, "/", "index.html") {
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Handle directory paths ending with "/" to avoid redirect loops
|
|
strippedPath := "/" + filePath
|
|
if serveFileDirectly(w, r, staticFS, strippedPath, "index.html") {
|
|
return
|
|
}
|
|
|
|
// Check if file exists in staticFS
|
|
_, err := staticFS.Open(filePath)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
// Create a file server and serve the file
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
|
|
// Temporarily modify the URL path for the file server
|
|
originalPath := r.URL.Path
|
|
r.URL.Path = "/" + filePath
|
|
fileServer.ServeHTTP(w, r)
|
|
r.URL.Path = originalPath
|
|
}
|
|
}
|