mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-23 09:52:01 +01:00
This is a large-scale refactoring across the codebase that replaces the custom `gperr.Error` type with Go's standard `error` interface. The changes include: - Replacing `gperr.Error` return types with `error` in function signatures - Using `errors.New()` and `fmt.Errorf()` instead of `gperr.New()` and `gperr.Errorf()` - Using `%w` format verb for error wrapping instead of `.With()` method - Replacing `gperr.Subject()` calls with `gperr.PrependSubject()` - Converting error logging from `gperr.Log*()` functions to zerolog's `.Err().Msg()` pattern - Update NewLogger to handle multiline error message - Updating `goutils` submodule to latest commit This refactoring aligns with Go idioms and removes the dependency on custom error handling abstractions in favor of standard library patterns.
199 lines
5.5 KiB
Go
199 lines
5.5 KiB
Go
package idlewatcher
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/yusing/godoxy/internal/homepage/icons"
|
|
iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch"
|
|
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
|
|
httputils "github.com/yusing/goutils/http"
|
|
|
|
_ "unsafe"
|
|
)
|
|
|
|
type ForceCacheControl struct {
|
|
expires string
|
|
http.ResponseWriter
|
|
}
|
|
|
|
func (f *ForceCacheControl) WriteHeader(code int) {
|
|
f.ResponseWriter.Header().Set("Cache-Control", "must-revalidate")
|
|
f.ResponseWriter.Header().Set("Expires", f.expires)
|
|
f.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (f *ForceCacheControl) Unwrap() http.ResponseWriter {
|
|
return f.ResponseWriter
|
|
}
|
|
|
|
// ServeHTTP implements http.Handler.
|
|
func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
|
|
shouldNext := w.wakeFromHTTP(rw, r)
|
|
if !shouldNext {
|
|
return
|
|
}
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
default:
|
|
f := &ForceCacheControl{expires: w.expires().Format(http.TimeFormat), ResponseWriter: rw}
|
|
w.rp.ServeHTTP(f, r)
|
|
}
|
|
}
|
|
|
|
func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) {
|
|
// Create a dedicated channel for this SSE connection and register it
|
|
eventCh := make(chan *WakeEvent, 10)
|
|
w.eventChs.Store(eventCh, struct{}{})
|
|
// Clean up when done
|
|
defer func() {
|
|
w.eventChs.Delete(eventCh)
|
|
close(eventCh)
|
|
}()
|
|
|
|
// Set SSE headers
|
|
rw.Header().Set("Content-Type", "text/event-stream")
|
|
rw.Header().Set("Cache-Control", "no-cache")
|
|
rw.Header().Set("Connection", "keep-alive")
|
|
rw.Header().Set("Access-Control-Allow-Origin", "*")
|
|
rw.Header().Set("Access-Control-Allow-Headers", "Cache-Control")
|
|
|
|
controller := http.NewResponseController(rw)
|
|
ctx := r.Context()
|
|
|
|
// Send historical events first
|
|
w.eventHistoryMu.RLock()
|
|
historicalEvents := make([]WakeEvent, len(w.eventHistory))
|
|
copy(historicalEvents, w.eventHistory)
|
|
w.eventHistoryMu.RUnlock()
|
|
|
|
for _, event := range historicalEvents {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
err := errors.Join(event.WriteSSE(rw), controller.Flush())
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to write SSE event")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Listen for new events and send them to client
|
|
for {
|
|
select {
|
|
case event := <-eventCh:
|
|
err := errors.Join(event.WriteSSE(rw), controller.Flush())
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to write SSE event")
|
|
return
|
|
}
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *Watcher) getFavIcon(ctx context.Context) (result iconfetch.Result, err error) {
|
|
r := w.route
|
|
hp := r.HomepageItem()
|
|
if hp.Icon != nil {
|
|
if hp.Icon.Source == icons.SourceRelative {
|
|
result, err = iconfetch.FindIcon(ctx, r, *hp.Icon.FullURL, icons.VariantNone)
|
|
} else {
|
|
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon)
|
|
}
|
|
} else {
|
|
// try extract from "link[rel=icon]"
|
|
result, err = iconfetch.FindIcon(ctx, r, "/", icons.VariantNone)
|
|
}
|
|
if result.StatusCode == 0 {
|
|
result.StatusCode = http.StatusOK
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func serveStaticContent(rw http.ResponseWriter, status int, contentType string, content []byte) {
|
|
rw.Header().Set("Content-Type", contentType)
|
|
rw.Header().Set("Content-Length", strconv.Itoa(len(content)))
|
|
rw.WriteHeader(status)
|
|
rw.Write(content)
|
|
}
|
|
|
|
func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) {
|
|
w.resetIdleTimer()
|
|
|
|
// handle static files
|
|
switch r.URL.Path {
|
|
case idlewatcher.FavIconPath:
|
|
result, err := w.getFavIcon(r.Context())
|
|
if err != nil {
|
|
rw.WriteHeader(result.StatusCode)
|
|
fmt.Fprint(rw, err)
|
|
return false
|
|
}
|
|
serveStaticContent(rw, result.StatusCode, result.ContentType(), result.Icon)
|
|
return false
|
|
case idlewatcher.LoadingPageCSSPath:
|
|
serveStaticContent(rw, http.StatusOK, "text/css", cssBytes)
|
|
return false
|
|
case idlewatcher.LoadingPageJSPath:
|
|
serveStaticContent(rw, http.StatusOK, "application/javascript", jsBytes)
|
|
return false
|
|
case idlewatcher.WakeEventsPath:
|
|
w.handleWakeEventsSSE(rw, r)
|
|
return false
|
|
}
|
|
|
|
// Allow request to proceed if the container is already ready.
|
|
// This check occurs after serving static files because a container can become ready quickly;
|
|
// otherwise, requests for assets may get a 404, leaving the user stuck on the loading screen.
|
|
if w.ready() {
|
|
return true
|
|
}
|
|
|
|
// Check if start endpoint is configured and request path matches
|
|
if w.cfg.StartEndpoint != "" && r.URL.Path != w.cfg.StartEndpoint {
|
|
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
|
|
return false
|
|
}
|
|
|
|
accept := httputils.GetAccept(r.Header)
|
|
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
|
|
|
|
err := w.Wake(r.Context())
|
|
if err != nil {
|
|
log.Err(err).Msg("Failed to wake container")
|
|
if !acceptHTML {
|
|
http.Error(rw, "Failed to wake container", http.StatusInternalServerError)
|
|
return false
|
|
}
|
|
}
|
|
|
|
if !acceptHTML || w.cfg.NoLoadingPage {
|
|
// send a continue response to prevent client wait-header timeout
|
|
rw.WriteHeader(http.StatusContinue)
|
|
ready := w.waitForReady(r.Context())
|
|
if !ready {
|
|
serveStaticContent(rw, http.StatusInternalServerError, "text/plain", []byte("Timeout waiting for container to become ready"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Send a loading response to the client
|
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
rw.Header().Set("Cache-Control", "no-cache")
|
|
rw.Header().Add("Cache-Control", "no-store")
|
|
rw.Header().Add("Cache-Control", "must-revalidate")
|
|
rw.Header().Add("Connection", "close")
|
|
_ = w.writeLoadingPage(rw)
|
|
return false
|
|
}
|