2023-08-21 00:27:54 +01:00
package serve
import (
2024-08-16 16:48:50 +01:00
"context"
2025-01-05 18:41:38 +00:00
_ "embed"
2024-08-16 16:48:50 +01:00
"github.com/1f349/bluebell/database"
2025-01-05 18:41:38 +00:00
"github.com/1f349/bluebell/logger"
2023-08-21 00:27:54 +01:00
"github.com/spf13/afero"
2025-01-05 18:41:38 +00:00
"html/template"
2024-08-16 16:48:50 +01:00
"net"
2023-08-21 00:27:54 +01:00
"net/http"
"os"
"path"
"path/filepath"
2025-01-05 18:41:38 +00:00
"strconv"
2023-08-21 00:27:54 +01:00
"strings"
2025-01-05 18:41:38 +00:00
"time"
2023-08-21 00:27:54 +01:00
)
var (
2025-01-05 18:41:38 +00:00
//go:embed missing-branch.go.html
missingBranchHtml string
missingBranchTemplate = template . Must ( template . New ( "missingBranchHtml" ) . Parse ( missingBranchHtml ) )
2023-08-21 00:27:54 +01:00
indexFiles = [ ] func ( p string ) string {
func ( p string ) string { return p } ,
2025-01-05 18:41:38 +00:00
func ( p string ) string { return p + ".html" } ,
func ( p string ) string { return path . Join ( p , "index.html" ) } ,
2023-08-21 00:27:54 +01:00
}
)
2025-01-05 18:41:38 +00:00
func isInvalidIndexPath ( p string ) bool {
switch p {
case "." , ".html" :
return true
}
return false
}
const (
BetaCookieName = "__bluebell-site-beta"
BetaSwitchPath = "/__bluebell-switch-beta"
BetaExpiry = 24 * time . Hour
NoCacheQuery = "/?__bluebell-no-cache="
)
2025-01-08 17:52:23 +00:00
type serveQueries interface {
2025-01-05 18:41:38 +00:00
GetLastUpdatedByDomainBranch ( ctx context . Context , params database . GetLastUpdatedByDomainBranchParams ) ( time . Time , error )
2024-08-16 16:48:50 +01:00
}
2025-01-08 17:52:23 +00:00
func New ( storage afero . Fs , db serveQueries ) * Handler {
2025-01-05 18:41:38 +00:00
return & Handler { storage , db }
2023-08-21 00:27:54 +01:00
}
type Handler struct {
storageFs afero . Fs
2025-01-08 17:52:23 +00:00
db serveQueries
2023-08-21 00:27:54 +01:00
}
2025-01-05 18:41:38 +00:00
func cacheBuster ( rw http . ResponseWriter , req * http . Request ) {
header := rw . Header ( )
header . Set ( "Cache-Control" , "no-cache, no-store, must-revalidate" )
header . Set ( "Pragma" , "no-cache" )
header . Set ( "Expires" , "0" )
http . Redirect ( rw , req , NoCacheQuery + strconv . FormatInt ( time . Now ( ) . Unix ( ) , 16 ) , http . StatusFound )
}
func ( h * Handler ) ServeHTTP ( rw http . ResponseWriter , req * http . Request ) {
2024-08-16 16:48:50 +01:00
host , _ , err := net . SplitHostPort ( req . Host )
if err != nil {
2025-01-05 18:41:38 +00:00
host = req . Host
2024-08-16 16:48:50 +01:00
}
2025-01-05 18:41:38 +00:00
// detect beta switch path
if req . URL . Path == BetaSwitchPath {
q := req . URL . Query ( )
// init cookie
baseCookie := & http . Cookie {
Name : BetaCookieName ,
Path : "/" ,
HttpOnly : true ,
SameSite : http . SameSiteLaxMode ,
}
// reset beta
if q . Has ( "reset" ) {
baseCookie . MaxAge = - 1
http . SetCookie ( rw , baseCookie )
cacheBuster ( rw , req )
return
}
// set beta branch
baseCookie . Value = q . Get ( "branch" )
baseCookie . Expires = time . Now ( ) . Add ( BetaExpiry )
http . SetCookie ( rw , baseCookie )
cacheBuster ( rw , req )
2023-08-21 00:27:54 +01:00
return
}
2025-01-05 18:41:38 +00:00
// read the beta cookie
branchCookie , _ := req . Cookie ( BetaCookieName )
var branch = "@"
if branchCookie != nil {
branch += branchCookie . Value
}
updated , err := h . db . GetLastUpdatedByDomainBranch ( req . Context ( ) , database . GetLastUpdatedByDomainBranchParams { Domain : host , Branch : branch } )
if err != nil {
rw . WriteHeader ( http . StatusMisdirectedRequest )
_ = missingBranchTemplate . Execute ( rw , struct { Host string } { host } )
logger . Logger . Debug ( "Branch is not available" , "host" , host , "branch" , branch , "err" , err )
2023-08-21 00:27:54 +01:00
return
}
2025-01-05 18:41:38 +00:00
if h . tryServePath ( rw , req , host , branch , updated , req . URL . Path ) {
return // page has been served
}
// tryServePath found no matching files
2023-08-21 00:27:54 +01:00
http . Error ( rw , "404 Not Found" , http . StatusNotFound )
2025-01-05 18:41:38 +00:00
logger . Logger . Debug ( "No matching file was found" )
2023-08-21 00:27:54 +01:00
}
2025-01-05 18:41:38 +00:00
// tryServePath attempts to find a valid path from the indexFiles list
func ( h * Handler ) tryServePath ( rw http . ResponseWriter , req * http . Request , site , branch string , updated time . Time , p string ) bool {
2023-08-21 00:27:54 +01:00
for _ , i := range indexFiles {
2025-01-05 18:41:38 +00:00
// skip invalid paths "." and ".html"
p2 := path . Clean ( i ( p ) )
if isInvalidIndexPath ( p2 ) {
continue
}
if h . tryServeFile ( rw , req , site , branch , updated , p2 ) {
2023-08-21 00:27:54 +01:00
return true
}
}
return false
}
2025-01-05 18:41:38 +00:00
// tryServeFile attempts to serve the content of a file if the file can be found
//
// If a matching file can be found or an internal error has occurred then the return value is true to prevent further changes to the response.
//
// If branch == "@" then time based caching is enabled for subsequent page loads. Otherwise, time based caching is disabled to prevent stale beta content from being cached.
func ( h * Handler ) tryServeFile ( rw http . ResponseWriter , req * http . Request , site , branch string , updated time . Time , p string ) bool {
2024-08-16 16:48:50 +01:00
// prevent path traversal
if strings . Contains ( site , ".." ) || strings . Contains ( branch , ".." ) || strings . Contains ( p , ".." ) {
http . Error ( rw , "400 Bad Request" , http . StatusBadRequest )
return true
2023-08-21 00:27:54 +01:00
}
2025-01-05 18:41:38 +00:00
servePath := filepath . Join ( site , branch , p )
logger . Logger . Debug ( "Serving file" , "full" , servePath , "site" , site , "branch" , branch , "file" , p )
open , err := h . storageFs . Open ( servePath )
2023-08-21 00:27:54 +01:00
switch {
case err == nil :
2025-01-05 18:41:38 +00:00
// ignore directories
stat , err := open . Stat ( )
if err != nil || stat . IsDir ( ) {
return false
}
// disable timed cache for non-main branches
if branch != "@" {
updated = time . Time { }
}
http . ServeContent ( rw , req , p , updated , open )
2023-08-21 00:27:54 +01:00
case os . IsNotExist ( err ) :
// check next path
return false
default :
http . Error ( rw , "500 Internal Server Error" , http . StatusInternalServerError )
}
return true
}