2022-01-18 21:13:19 +00:00
package site
import (
2022-06-21 16:53:36 +00:00
"archive/tar"
2022-03-12 20:51:05 +00:00
"bytes"
2023-06-18 18:57:27 +00:00
"context"
2022-06-22 18:33:23 +00:00
"crypto/sha1" //#nosec // Not used for cryptography.
2023-06-18 18:57:27 +00:00
"database/sql"
2022-10-04 16:30:55 +00:00
_ "embed"
2022-06-22 18:33:23 +00:00
"encoding/hex"
2023-03-21 19:04:54 +00:00
"encoding/json"
2022-06-21 16:53:36 +00:00
"errors"
2022-01-18 21:13:19 +00:00
"fmt"
2023-03-21 19:04:54 +00:00
"html"
2022-10-04 16:30:55 +00:00
htmltemplate "html/template"
2022-03-12 20:51:05 +00:00
"io"
2022-01-18 21:13:19 +00:00
"io/fs"
"net/http"
2022-06-21 16:53:36 +00:00
"os"
2022-03-12 20:51:05 +00:00
"path"
"path/filepath"
2022-01-18 21:13:19 +00:00
"strings"
2023-01-17 18:38:08 +00:00
"sync"
2023-06-18 18:57:27 +00:00
"sync/atomic"
2022-03-12 20:51:05 +00:00
"text/template" // html/template escapes some nonces
"time"
2022-01-18 21:13:19 +00:00
2023-06-18 18:57:27 +00:00
"github.com/google/uuid"
2022-01-18 21:13:19 +00:00
"github.com/justinas/nosurf"
2022-06-21 16:53:36 +00:00
"github.com/klauspost/compress/zstd"
2022-01-18 21:13:19 +00:00
"github.com/unrolled/secure"
2022-06-21 16:53:36 +00:00
"golang.org/x/exp/slices"
2022-06-22 18:33:23 +00:00
"golang.org/x/sync/errgroup"
2023-01-17 18:38:08 +00:00
"golang.org/x/sync/singleflight"
2022-03-12 20:51:05 +00:00
"golang.org/x/xerrors"
2022-10-04 16:30:55 +00:00
2024-01-29 08:17:31 +00:00
"github.com/coder/coder/v2/coderd/appearance"
2023-08-18 18:55:43 +00:00
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/codersdk"
2022-01-18 21:13:19 +00:00
)
2022-10-04 16:30:55 +00:00
// We always embed the error page HTML because it it doesn't need to be built,
// and it's tiny and doesn't contribute much to the binary size.
var (
//go:embed static/error.html
errorHTML string
errorTemplate * htmltemplate . Template
2024-02-20 23:58:43 +00:00
//go:embed static/oauth2allow.html
oauthHTML string
oauthTemplate * htmltemplate . Template
2022-10-04 16:30:55 +00:00
)
2022-06-04 20:13:37 +00:00
2022-10-04 16:30:55 +00:00
func init ( ) {
var err error
errorTemplate , err = htmltemplate . New ( "error" ) . Parse ( errorHTML )
if err != nil {
panic ( err )
}
2024-02-20 23:58:43 +00:00
oauthTemplate , err = htmltemplate . New ( "error" ) . Parse ( oauthHTML )
if err != nil {
panic ( err )
}
2022-06-04 20:13:37 +00:00
}
2023-06-18 18:57:27 +00:00
type Options struct {
2024-01-29 08:17:31 +00:00
BinFS http . FileSystem
BinHashes map [ string ] string
Database database . Store
SiteFS fs . FS
OAuth2Configs * httpmw . OAuth2Configs
DocsURL string
2024-04-30 00:50:49 +00:00
BuildInfo codersdk . BuildInfoResponse
2024-01-29 08:17:31 +00:00
AppearanceFetcher * atomic . Pointer [ appearance . Fetcher ]
2023-06-18 18:57:27 +00:00
}
func New ( opts * Options ) * Handler {
2024-01-29 08:17:31 +00:00
if opts . AppearanceFetcher == nil {
daf := atomic . Pointer [ appearance . Fetcher ] { }
daf . Store ( & appearance . DefaultFetcher )
opts . AppearanceFetcher = & daf
}
2023-06-18 18:57:27 +00:00
handler := & Handler {
opts : opts ,
secureHeaders : secureHeaders ( ) ,
}
2022-03-12 20:51:05 +00:00
// html files are handled by a text/template. Non-html files
// are served by the default file server.
2023-06-18 18:57:27 +00:00
var err error
handler . htmlTemplates , err = findAndParseHTMLFiles ( opts . SiteFS )
2022-03-12 20:51:05 +00:00
if err != nil {
2023-06-18 18:57:27 +00:00
panic ( fmt . Sprintf ( "Failed to parse html files: %v" , err ) )
2022-01-18 21:13:19 +00:00
}
2023-06-18 18:57:27 +00:00
binHashCache := newBinHashCache ( opts . BinFS , opts . BinHashes )
2023-01-17 18:38:08 +00:00
2022-06-21 16:53:36 +00:00
mux := http . NewServeMux ( )
2022-12-21 21:06:38 +00:00
mux . Handle ( "/bin/" , http . StripPrefix ( "/bin" , http . HandlerFunc ( func ( rw http . ResponseWriter , r * http . Request ) {
// Convert underscores in the filename to hyphens. We eventually want to
// change our hyphen-based filenames to underscores, but we need to
// support both for now.
r . URL . Path = strings . ReplaceAll ( r . URL . Path , "_" , "-" )
2023-01-17 18:38:08 +00:00
// Set ETag header to the SHA1 hash of the file contents.
name := filePath ( r . URL . Path )
if name == "" || name == "/" {
2024-02-20 21:50:30 +00:00
// Serve the directory listing. This intentionally allows directory listings to
// be served. This file system should not contain anything sensitive.
2023-06-18 18:57:27 +00:00
http . FileServer ( opts . BinFS ) . ServeHTTP ( rw , r )
2023-01-17 18:38:08 +00:00
return
}
if strings . Contains ( name , "/" ) {
// We only serve files from the root of this directory, so avoid any
// shenanigans by blocking slashes in the URL path.
http . NotFound ( rw , r )
return
}
hash , err := binHashCache . getHash ( name )
if xerrors . Is ( err , os . ErrNotExist ) {
http . NotFound ( rw , r )
return
}
if err != nil {
http . Error ( rw , err . Error ( ) , http . StatusInternalServerError )
return
}
// ETag header needs to be quoted.
rw . Header ( ) . Set ( "ETag" , fmt . Sprintf ( ` %q ` , hash ) )
// http.FileServer will see the ETag header and automatically handle
// If-Match and If-None-Match headers on the request properly.
2023-06-18 18:57:27 +00:00
http . FileServer ( opts . BinFS ) . ServeHTTP ( rw , r )
2022-12-21 21:06:38 +00:00
} ) ) )
2024-02-20 21:50:30 +00:00
mux . Handle ( "/" , http . FileServer (
http . FS (
// OnlyFiles is a wrapper around the file system that prevents directory
// listings. Directory listings are not required for the site file system, so we
// exclude it as a security measure. In practice, this file system comes from our
// open source code base, but this is considered a best practice for serving
// static files.
OnlyFiles ( opts . SiteFS ) ) ) ,
)
2024-04-30 00:50:49 +00:00
buildInfoResponse , err := json . Marshal ( opts . BuildInfo )
2023-03-21 19:04:54 +00:00
if err != nil {
panic ( "failed to marshal build info: " + err . Error ( ) )
}
2023-06-18 18:57:27 +00:00
handler . buildInfoJSON = html . EscapeString ( string ( buildInfoResponse ) )
handler . handler = mux . ServeHTTP
2023-03-21 19:04:54 +00:00
2023-06-18 18:57:27 +00:00
return handler
2022-03-12 20:51:05 +00:00
}
2023-06-18 18:57:27 +00:00
type Handler struct {
opts * Options
secureHeaders * secure . Secure
handler http . HandlerFunc
htmlTemplates * template . Template
2023-03-21 19:04:54 +00:00
buildInfoJSON string
2023-06-18 18:57:27 +00:00
2023-06-30 15:32:35 +00:00
// RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the
// regions if the user does not have the correct permissions.
RegionsFetcher func ( ctx context . Context ) ( any , error )
2023-06-18 18:57:27 +00:00
Entitlements atomic . Pointer [ codersdk . Entitlements ]
Experiments atomic . Pointer [ codersdk . Experiments ]
}
func ( h * Handler ) ServeHTTP ( rw http . ResponseWriter , r * http . Request ) {
err := h . secureHeaders . Process ( rw , r )
if err != nil {
return
}
// reqFile is the static file requested
reqFile := filePath ( r . URL . Path )
state := htmlState {
// Token is the CSRF token for the given request
CSRF : csrfState { Token : nosurf . Token ( r ) } ,
BuildInfo : h . buildInfoJSON ,
2023-07-19 16:57:43 +00:00
DocsURL : h . opts . DocsURL ,
2023-06-18 18:57:27 +00:00
}
// First check if it's a file we have in our templates
if h . serveHTML ( rw , r , reqFile , state ) {
return
}
switch {
// If requesting binaries, serve straight up.
case reqFile == "bin" || strings . HasPrefix ( reqFile , "bin/" ) :
h . handler . ServeHTTP ( rw , r )
return
// If the original file path exists we serve it.
case h . exists ( reqFile ) :
if ShouldCacheFile ( reqFile ) {
rw . Header ( ) . Add ( "Cache-Control" , "public, max-age=31536000, immutable" )
}
h . handler . ServeHTTP ( rw , r )
return
}
// Serve the file assuming it's an html file
// This matches paths like `/app/terminal.html`
r . URL . Path = strings . TrimSuffix ( r . URL . Path , "/" )
r . URL . Path += ".html"
reqFile = filePath ( r . URL . Path )
// All html files should be served by the htmlFile templates
if h . serveHTML ( rw , r , reqFile , state ) {
return
}
// If we don't have the file... we should redirect to `/`
// for our single-page-app.
r . URL . Path = "/"
if h . serveHTML ( rw , r , "" , state ) {
return
}
// This will send a correct 404
h . handler . ServeHTTP ( rw , r )
2022-03-12 20:51:05 +00:00
}
// filePath returns the filepath of the requested file.
func filePath ( p string ) string {
if ! strings . HasPrefix ( p , "/" ) {
p = "/" + p
}
return strings . TrimPrefix ( path . Clean ( p ) , "/" )
}
2023-06-18 18:57:27 +00:00
func ( h * Handler ) exists ( filePath string ) bool {
f , err := h . opts . SiteFS . Open ( filePath )
2022-03-12 20:51:05 +00:00
if err == nil {
_ = f . Close ( )
2022-01-18 21:13:19 +00:00
}
2022-03-12 20:51:05 +00:00
return err == nil
2022-01-18 21:13:19 +00:00
}
type htmlState struct {
2023-06-18 18:57:27 +00:00
CSRF csrfState
// Below are HTML escaped JSON strings of the respective structs.
2023-09-28 08:50:40 +00:00
ApplicationName string
LogoURL string
2023-06-18 18:57:27 +00:00
BuildInfo string
User string
Entitlements string
Appearance string
Experiments string
2023-06-19 16:23:26 +00:00
Regions string
2023-07-19 16:57:43 +00:00
DocsURL string
2022-01-18 21:13:19 +00:00
}
type csrfState struct {
Token string
}
2022-03-12 20:51:05 +00:00
func ShouldCacheFile ( reqFile string ) bool {
// Images, favicons and uniquely content hashed bundle assets should be
// cached. By default, we cache everything in the site/out directory except
2022-09-29 21:28:44 +00:00
// for deny-listed items enumerated here. The reason for this approach is that
// cache invalidation techniques should be used by default for all build
// processed assets. The scenarios where we don't use cache invalidation
// techniques are one-offs or things that should have invalidation in the
// future.
2022-03-12 20:51:05 +00:00
denyListedSuffixes := [ ] string {
".html" ,
"worker.js" ,
}
for _ , suffix := range denyListedSuffixes {
if strings . HasSuffix ( reqFile , suffix ) {
return false
}
}
return true
}
2023-06-18 18:57:27 +00:00
func ( h * Handler ) serveHTML ( resp http . ResponseWriter , request * http . Request , reqPath string , state htmlState ) bool {
2023-07-12 12:38:30 +00:00
if data , err := h . renderHTMLWithState ( request , reqPath , state ) ; err == nil {
2022-03-12 20:51:05 +00:00
if reqPath == "" {
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
reqPath = "index.html"
}
http . ServeContent ( resp , request , reqPath , time . Time { } , bytes . NewReader ( data ) )
return true
}
return false
}
2023-08-30 21:50:43 +00:00
func execTmpl ( tmpl * template . Template , state htmlState ) ( [ ] byte , error ) {
var buf bytes . Buffer
err := tmpl . Execute ( & buf , state )
return buf . Bytes ( ) , err
}
2022-03-12 20:51:05 +00:00
// renderWithState will render the file using the given nonce if the file exists
// as a template. If it does not, it will return an error.
2023-07-12 12:38:30 +00:00
func ( h * Handler ) renderHTMLWithState ( r * http . Request , filePath string , state htmlState ) ( [ ] byte , error ) {
2024-01-29 08:17:31 +00:00
af := * ( h . opts . AppearanceFetcher . Load ( ) )
2022-03-12 20:51:05 +00:00
if filePath == "" {
filePath = "index.html"
}
2023-06-18 18:57:27 +00:00
tmpl := h . htmlTemplates . Lookup ( filePath )
if tmpl == nil {
return nil , xerrors . Errorf ( "template %q not found" , filePath )
}
// Cookies are sent when requesting HTML, so we can get the user
// and pre-populate the state for the frontend to reduce requests.
2023-07-12 12:38:30 +00:00
// We use a noop response writer because we don't want to write
// anything to the response and break the HTML, an error means we
// simply don't pre-populate the state.
noopRW := noopResponseWriter { }
apiKey , actor , ok := httpmw . ExtractAPIKey ( noopRW , r , httpmw . ExtractAPIKeyConfig {
2023-07-10 14:23:41 +00:00
Optional : true ,
DB : h . opts . Database ,
OAuth2Configs : h . opts . OAuth2Configs ,
// Special case for site, we can always disable refresh here because
// the frontend will perform API requests if this fails.
DisableSessionExpiryRefresh : true ,
2023-07-11 13:30:33 +00:00
RedirectToLogin : false ,
SessionTokenFunc : nil ,
2023-06-18 18:57:27 +00:00
} )
2023-08-30 21:50:43 +00:00
if ! ok || apiKey == nil || actor == nil {
2023-09-28 08:50:40 +00:00
var cfg codersdk . AppearanceConfig
2024-01-29 08:17:31 +00:00
// nolint:gocritic // User is not expected to be signed in.
ctx := dbauthz . AsSystemRestricted ( r . Context ( ) )
cfg , _ = af . Fetch ( ctx )
2023-09-28 08:50:40 +00:00
state . ApplicationName = applicationNameOrDefault ( cfg )
state . LogoURL = cfg . LogoURL
2023-08-30 21:50:43 +00:00
return execTmpl ( tmpl , state )
}
2024-03-29 15:14:27 +00:00
ctx := dbauthz . As ( r . Context ( ) , * actor )
2023-08-30 21:50:43 +00:00
var eg errgroup . Group
var user database . User
orgIDs := [ ] uuid . UUID { }
eg . Go ( func ( ) error {
var err error
user , err = h . opts . Database . GetUserByID ( ctx , apiKey . UserID )
return err
} )
eg . Go ( func ( ) error {
memberIDs , err := h . opts . Database . GetOrganizationIDsByMemberIDs ( ctx , [ ] uuid . UUID { apiKey . UserID } )
if errors . Is ( err , sql . ErrNoRows ) || len ( memberIDs ) == 0 {
return nil
}
if err != nil {
return nil
}
orgIDs = memberIDs [ 0 ] . OrganizationIDs
return err
} )
err := eg . Wait ( )
if err == nil {
var wg sync . WaitGroup
wg . Add ( 1 )
go func ( ) {
defer wg . Done ( )
user , err := json . Marshal ( db2sdk . User ( user , orgIDs ) )
if err == nil {
state . User = html . EscapeString ( string ( user ) )
2023-06-18 18:57:27 +00:00
}
2023-08-30 21:50:43 +00:00
} ( )
entitlements := h . Entitlements . Load ( )
if entitlements != nil {
2023-06-19 16:23:26 +00:00
wg . Add ( 1 )
go func ( ) {
defer wg . Done ( )
2023-08-30 21:50:43 +00:00
entitlements , err := json . Marshal ( entitlements )
2023-06-18 18:57:27 +00:00
if err == nil {
2023-08-30 21:50:43 +00:00
state . Entitlements = html . EscapeString ( string ( entitlements ) )
2023-06-18 18:57:27 +00:00
}
2023-06-19 16:23:26 +00:00
} ( )
2023-08-30 21:50:43 +00:00
}
2024-01-29 08:17:31 +00:00
wg . Add ( 1 )
go func ( ) {
defer wg . Done ( )
cfg , err := af . Fetch ( ctx )
if err == nil {
appr , err := json . Marshal ( cfg )
2023-08-30 21:50:43 +00:00
if err == nil {
2024-01-29 08:17:31 +00:00
state . Appearance = html . EscapeString ( string ( appr ) )
state . ApplicationName = applicationNameOrDefault ( cfg )
state . LogoURL = cfg . LogoURL
2023-08-30 21:50:43 +00:00
}
2024-01-29 08:17:31 +00:00
}
} ( )
2023-08-30 21:50:43 +00:00
if h . RegionsFetcher != nil {
wg . Add ( 1 )
go func ( ) {
defer wg . Done ( )
regions , err := h . RegionsFetcher ( ctx )
if err == nil {
regions , err := json . Marshal ( regions )
2023-06-19 16:23:26 +00:00
if err == nil {
2023-08-30 21:50:43 +00:00
state . Regions = html . EscapeString ( string ( regions ) )
2023-06-19 16:23:26 +00:00
}
2023-08-30 21:50:43 +00:00
}
} ( )
2023-06-18 18:57:27 +00:00
}
2023-08-30 21:50:43 +00:00
experiments := h . Experiments . Load ( )
if experiments != nil {
wg . Add ( 1 )
go func ( ) {
defer wg . Done ( )
experiments , err := json . Marshal ( experiments )
if err == nil {
state . Experiments = html . EscapeString ( string ( experiments ) )
}
} ( )
}
wg . Wait ( )
2023-06-18 18:57:27 +00:00
}
2023-08-30 21:50:43 +00:00
return execTmpl ( tmpl , state )
2022-03-12 20:51:05 +00:00
}
2023-07-12 12:38:30 +00:00
// noopResponseWriter is a response writer that does nothing.
type noopResponseWriter struct { }
func ( noopResponseWriter ) Header ( ) http . Header { return http . Header { } }
func ( noopResponseWriter ) Write ( p [ ] byte ) ( int , error ) { return len ( p ) , nil }
func ( noopResponseWriter ) WriteHeader ( int ) { }
2022-01-18 21:13:19 +00:00
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
// It adds various headers to enforce browser security features.
2023-06-18 18:57:27 +00:00
func secureHeaders ( ) * secure . Secure {
2022-01-18 21:13:19 +00:00
// Permissions-Policy can be used to disabled various browser features that we do not use.
// This can prevent an embedded iframe from accessing these features.
// If we support arbitrary iframes such as generic applications, we might need to add permissions
// based on the app here.
permissions := strings . Join ( [ ] string {
// =() means it is disabled
"accelerometer=()" ,
"autoplay=()" ,
"battery=()" ,
"camera=()" ,
"document-domain=()" ,
"geolocation=()" ,
"gyroscope=()" ,
"magnetometer=()" ,
"microphone=()" ,
"midi=()" ,
"payment=()" ,
"usb=()" ,
"vr=()" ,
"screen-wake-lock=()" ,
"xr-spatial-tracking=()" ,
} , ", " )
return secure . New ( secure . Options {
PermissionsPolicy : permissions ,
2022-08-15 14:12:34 +00:00
// Prevent the browser from sending Referrer header with requests
2022-01-18 21:13:19 +00:00
ReferrerPolicy : "no-referrer" ,
2023-06-18 18:57:27 +00:00
} )
2022-01-18 21:13:19 +00:00
}
2022-03-12 20:51:05 +00:00
2023-06-18 18:57:27 +00:00
// findAndParseHTMLFiles recursively walks the file system passed finding all *.html files.
2022-03-12 20:51:05 +00:00
// The template returned has all html files parsed.
2023-06-18 18:57:27 +00:00
func findAndParseHTMLFiles ( files fs . FS ) ( * template . Template , error ) {
2022-03-12 20:51:05 +00:00
// root is the collection of html templates. All templates are named by their pathing.
// So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
root := template . New ( "" )
rootPath := "."
err := fs . WalkDir ( files , rootPath , func ( filePath string , directory fs . DirEntry , err error ) error {
if err != nil {
return err
}
if directory . IsDir ( ) {
return nil
}
if filepath . Ext ( directory . Name ( ) ) != ".html" {
return nil
}
file , err := files . Open ( filePath )
if err != nil {
return err
}
data , err := io . ReadAll ( file )
if err != nil {
return err
}
tPath := strings . TrimPrefix ( filePath , rootPath + string ( filepath . Separator ) )
_ , err = root . New ( tPath ) . Parse ( string ( data ) )
if err != nil {
return err
}
return nil
} )
if err != nil {
return nil , err
}
2023-06-18 18:57:27 +00:00
return root , nil
2022-03-12 20:51:05 +00:00
}
2022-06-21 16:53:36 +00:00
2023-01-17 18:38:08 +00:00
// ExtractOrReadBinFS checks the provided fs for compressed coder binaries and
// extracts them into dest/bin if found. As a fallback, the provided FS is
// checked for a /bin directory, if it is non-empty it is returned. Finally
// dest/bin is returned as a fallback allowing binaries to be manually placed in
// dest (usually ${CODER_CACHE_DIRECTORY}/site/bin).
//
// Returns a http.FileSystem that serves unpacked binaries, and a map of binary
// name to SHA1 hash. The returned hash map may be incomplete or contain hashes
// for missing files.
func ExtractOrReadBinFS ( dest string , siteFS fs . FS ) ( http . FileSystem , map [ string ] string , error ) {
2022-06-21 16:53:36 +00:00
if dest == "" {
// No destination on fs, embedded fs is the only option.
binFS , err := fs . Sub ( siteFS , "bin" )
if err != nil {
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . Errorf ( "cache path is empty and embedded fs does not have /bin: %w" , err )
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
return http . FS ( binFS ) , nil , nil
2022-06-21 16:53:36 +00:00
}
dest = filepath . Join ( dest , "bin" )
mkdest := func ( ) ( http . FileSystem , error ) {
err := os . MkdirAll ( dest , 0 o700 )
if err != nil {
return nil , xerrors . Errorf ( "mkdir failed: %w" , err )
}
return http . Dir ( dest ) , nil
}
archive , err := siteFS . Open ( "bin/coder.tar.zst" )
if err != nil {
if xerrors . Is ( err , fs . ErrNotExist ) {
files , err := fs . ReadDir ( siteFS , "bin" )
if err != nil {
if xerrors . Is ( err , fs . ErrNotExist ) {
2023-01-17 18:38:08 +00:00
// Given fs does not have a bin directory, serve from cache
// directory without extracting anything.
binFS , err := mkdest ( )
if err != nil {
return nil , nil , xerrors . Errorf ( "mkdest failed: %w" , err )
}
return binFS , map [ string ] string { } , nil
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . Errorf ( "site fs read dir failed: %w" , err )
2022-06-21 16:53:36 +00:00
}
if len ( filterFiles ( files , "GITKEEP" ) ) > 0 {
2023-01-17 18:38:08 +00:00
// If there are other files than bin/GITKEEP, serve the files.
2022-06-21 16:53:36 +00:00
binFS , err := fs . Sub ( siteFS , "bin" )
if err != nil {
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . Errorf ( "site fs sub dir failed: %w" , err )
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
return http . FS ( binFS ) , nil , nil
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
// Nothing we can do, serve the cache directory, thus allowing
// binaries to be placed there.
binFS , err := mkdest ( )
if err != nil {
return nil , nil , xerrors . Errorf ( "mkdest failed: %w" , err )
}
return binFS , map [ string ] string { } , nil
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . Errorf ( "open coder binary archive failed: %w" , err )
2022-06-21 16:53:36 +00:00
}
defer archive . Close ( )
2023-01-17 18:38:08 +00:00
binFS , err := mkdest ( )
2022-06-21 16:53:36 +00:00
if err != nil {
2023-01-17 18:38:08 +00:00
return nil , nil , err
}
shaFiles , err := parseSHA1 ( siteFS )
if err != nil {
return nil , nil , xerrors . Errorf ( "parse sha1 file failed: %w" , err )
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
ok , err := verifyBinSha1IsCurrent ( dest , siteFS , shaFiles )
2022-06-21 16:53:36 +00:00
if err != nil {
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . Errorf ( "verify coder binaries sha1 failed: %w" , err )
2022-06-21 16:53:36 +00:00
}
2022-06-22 18:33:23 +00:00
if ! ok {
n , err := extractBin ( dest , archive )
if err != nil {
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . Errorf ( "extract coder binaries failed: %w" , err )
2022-06-22 18:33:23 +00:00
}
if n == 0 {
2023-01-17 18:38:08 +00:00
return nil , nil , xerrors . New ( "no files were extracted from coder binaries archive" )
2022-06-22 18:33:23 +00:00
}
2022-06-21 16:53:36 +00:00
}
2023-01-17 18:38:08 +00:00
return binFS , shaFiles , nil
2022-06-21 16:53:36 +00:00
}
func filterFiles ( files [ ] fs . DirEntry , names ... string ) [ ] fs . DirEntry {
var filtered [ ] fs . DirEntry
for _ , f := range files {
if slices . Contains ( names , f . Name ( ) ) {
continue
}
filtered = append ( filtered , f )
}
return filtered
}
2022-06-22 18:33:23 +00:00
// errHashMismatch is a sentinel error used in verifyBinSha1IsCurrent.
var errHashMismatch = xerrors . New ( "hash mismatch" )
2023-01-17 18:38:08 +00:00
func parseSHA1 ( siteFS fs . FS ) ( map [ string ] string , error ) {
b , err := fs . ReadFile ( siteFS , "bin/coder.sha1" )
2022-06-22 18:33:23 +00:00
if err != nil {
2023-01-17 18:38:08 +00:00
return nil , xerrors . Errorf ( "read coder sha1 from embedded fs failed: %w" , err )
2022-06-22 18:33:23 +00:00
}
2023-01-17 18:38:08 +00:00
shaFiles := make ( map [ string ] string )
for _ , line := range bytes . Split ( bytes . TrimSpace ( b ) , [ ] byte { '\n' } ) {
2022-06-22 18:33:23 +00:00
parts := bytes . Split ( line , [ ] byte { ' ' , '*' } )
if len ( parts ) != 2 {
2023-01-17 18:38:08 +00:00
return nil , xerrors . Errorf ( "malformed sha1 file: %w" , err )
2022-06-22 18:33:23 +00:00
}
2023-01-17 18:38:08 +00:00
shaFiles [ string ( parts [ 1 ] ) ] = strings . ToLower ( string ( parts [ 0 ] ) )
2022-06-22 18:33:23 +00:00
}
if len ( shaFiles ) == 0 {
2023-01-17 18:38:08 +00:00
return nil , xerrors . Errorf ( "empty sha1 file: %w" , err )
2022-06-22 18:33:23 +00:00
}
2023-01-17 18:38:08 +00:00
return shaFiles , nil
}
func verifyBinSha1IsCurrent ( dest string , siteFS fs . FS , shaFiles map [ string ] string ) ( ok bool , err error ) {
b1 , err := fs . ReadFile ( siteFS , "bin/coder.sha1" )
if err != nil {
return false , xerrors . Errorf ( "read coder sha1 from embedded fs failed: %w" , err )
}
2022-06-22 18:33:23 +00:00
b2 , err := os . ReadFile ( filepath . Join ( dest , "coder.sha1" ) )
if err != nil {
if xerrors . Is ( err , fs . ErrNotExist ) {
return false , nil
}
return false , xerrors . Errorf ( "read coder sha1 failed: %w" , err )
}
// Check shasum files for equality for early-exit.
if ! bytes . Equal ( b1 , b2 ) {
return false , nil
}
var eg errgroup . Group
// Speed up startup by verifying files concurrently. Concurrency
// is limited to save resources / early-exit. Early-exit speed
// could be improved by using a context aware io.Reader and
// passing the context from errgroup.WithContext.
eg . SetLimit ( 3 )
// Verify the hash of each on-disk binary.
for file , hash1 := range shaFiles {
file := file
hash1 := hash1
eg . Go ( func ( ) error {
hash2 , err := sha1HashFile ( filepath . Join ( dest , file ) )
if err != nil {
if xerrors . Is ( err , fs . ErrNotExist ) {
return errHashMismatch
}
return xerrors . Errorf ( "hash file failed: %w" , err )
}
2023-01-17 18:38:08 +00:00
if ! strings . EqualFold ( hash1 , hash2 ) {
2022-06-22 18:33:23 +00:00
return errHashMismatch
}
return nil
} )
}
err = eg . Wait ( )
if err != nil {
if xerrors . Is ( err , errHashMismatch ) {
return false , nil
}
return false , err
}
return true , nil
}
// sha1HashFile computes a SHA1 hash of the file, returning the hex
// representation.
2023-01-17 18:38:08 +00:00
func sha1HashFile ( name string ) ( string , error ) {
2022-06-22 18:33:23 +00:00
//#nosec // Not used for cryptography.
hash := sha1 . New ( )
f , err := os . Open ( name )
if err != nil {
2023-01-17 18:38:08 +00:00
return "" , err
2022-06-22 18:33:23 +00:00
}
defer f . Close ( )
_ , err = io . Copy ( hash , f )
if err != nil {
2023-01-17 18:38:08 +00:00
return "" , err
2022-06-22 18:33:23 +00:00
}
b := make ( [ ] byte , hash . Size ( ) )
hash . Sum ( b [ : 0 ] )
2023-01-17 18:38:08 +00:00
return hex . EncodeToString ( b ) , nil
2022-06-22 18:33:23 +00:00
}
2022-08-01 13:29:52 +00:00
func extractBin ( dest string , r io . Reader ) ( numExtracted int , err error ) {
2022-06-21 16:53:36 +00:00
opts := [ ] zstd . DOption {
// Concurrency doesn't help us when decoding the tar and
// can actually slow us down.
zstd . WithDecoderConcurrency ( 1 ) ,
// Ignoring checksums can give a slight performance
2022-08-01 13:29:52 +00:00
// boost but it's probably not worth the reduced safety.
2022-06-21 16:53:36 +00:00
zstd . IgnoreChecksum ( false ) ,
// Allow the decoder to use more memory giving us a 2-3x
// performance boost.
zstd . WithDecoderLowmem ( false ) ,
}
zr , err := zstd . NewReader ( r , opts ... )
if err != nil {
return 0 , xerrors . Errorf ( "open zstd archive failed: %w" , err )
}
defer zr . Close ( )
tr := tar . NewReader ( zr )
n := 0
for {
h , err := tr . Next ( )
if err != nil {
if errors . Is ( err , io . EOF ) {
return n , nil
}
return n , xerrors . Errorf ( "read tar archive failed: %w" , err )
}
2022-12-19 19:25:59 +00:00
if h . Name == "." || strings . Contains ( h . Name , ".." ) {
continue
}
2022-06-21 16:53:36 +00:00
name := filepath . Join ( dest , filepath . Base ( h . Name ) )
f , err := os . Create ( name )
if err != nil {
return n , xerrors . Errorf ( "create file failed: %w" , err )
}
//#nosec // We created this tar, no risk of decompression bomb.
_ , err = io . Copy ( f , tr )
if err != nil {
_ = f . Close ( )
return n , xerrors . Errorf ( "write file contents failed: %w" , err )
}
err = f . Close ( )
if err != nil {
return n , xerrors . Errorf ( "close file failed: %w" , err )
}
n ++
}
}
2022-10-04 16:30:55 +00:00
// ErrorPageData contains the variables that are found in
// site/static/error.html.
type ErrorPageData struct {
2023-05-05 18:53:19 +00:00
Status int
// HideStatus will remove the status code from the page.
2024-04-26 15:52:53 +00:00
HideStatus bool
Title string
Description string
RetryEnabled bool
DashboardURL string
Warnings [ ] string
AdditionalInfo string
AdditionalButtonLink string
AdditionalButtonText string
2024-02-01 17:01:25 +00:00
RenderDescriptionMarkdown bool
2022-10-04 16:30:55 +00:00
}
// RenderStaticErrorPage renders the static error page. This is used by app
// requests to avoid dependence on the dashboard but maintain the ability to
// render a friendly error page on subdomains.
func RenderStaticErrorPage ( rw http . ResponseWriter , r * http . Request , data ErrorPageData ) {
type outerData struct {
Error ErrorPageData
2024-02-01 17:01:25 +00:00
ErrorDescriptionHTML htmltemplate . HTML
2022-10-04 16:30:55 +00:00
}
rw . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
rw . WriteHeader ( data . Status )
2024-02-01 17:01:25 +00:00
err := errorTemplate . Execute ( rw , outerData {
Error : data ,
ErrorDescriptionHTML : htmltemplate . HTML ( data . Description ) , //nolint:gosec // gosec thinks this is user-input, but it is from Coder deployment configuration.
} )
2022-10-04 16:30:55 +00:00
if err != nil {
httpapi . Write ( r . Context ( ) , rw , http . StatusInternalServerError , codersdk . Response {
Message : "Failed to render error page: " + err . Error ( ) ,
Detail : fmt . Sprintf ( "Original error was: %d %s, %s" , data . Status , data . Title , data . Description ) ,
} )
return
}
}
2023-01-17 18:38:08 +00:00
type binHashCache struct {
binFS http . FileSystem
hashes map [ string ] string
mut sync . RWMutex
sf singleflight . Group
sem chan struct { }
}
func newBinHashCache ( binFS http . FileSystem , binHashes map [ string ] string ) * binHashCache {
b := & binHashCache {
binFS : binFS ,
hashes : make ( map [ string ] string , len ( binHashes ) ) ,
mut : sync . RWMutex { } ,
sf : singleflight . Group { } ,
sem : make ( chan struct { } , 4 ) ,
}
// Make a copy since we're gonna be mutating it.
for k , v := range binHashes {
b . hashes [ k ] = v
}
return b
}
func ( b * binHashCache ) getHash ( name string ) ( string , error ) {
b . mut . RLock ( )
hash , ok := b . hashes [ name ]
b . mut . RUnlock ( )
if ok {
return hash , nil
}
// Avoid DOS by using a pool, and only doing work once per file.
v , err , _ := b . sf . Do ( name , func ( ) ( interface { } , error ) {
b . sem <- struct { } { }
defer func ( ) { <- b . sem } ( )
f , err := b . binFS . Open ( name )
if err != nil {
return "" , err
}
defer f . Close ( )
h := sha1 . New ( ) //#nosec // Not used for cryptography.
_ , err = io . Copy ( h , f )
if err != nil {
return "" , err
}
hash := hex . EncodeToString ( h . Sum ( nil ) )
b . mut . Lock ( )
b . hashes [ name ] = hash
b . mut . Unlock ( )
return hash , nil
} )
if err != nil {
return "" , err
}
//nolint:forcetypeassert
return strings . ToLower ( v . ( string ) ) , nil
}
2023-09-28 08:50:40 +00:00
func applicationNameOrDefault ( cfg codersdk . AppearanceConfig ) string {
if cfg . ApplicationName != "" {
return cfg . ApplicationName
}
return "Coder"
}
2024-02-20 21:50:30 +00:00
// OnlyFiles returns a new fs.FS that only contains files. If a directory is
// requested, os.ErrNotExist is returned. This prevents directory listings from
// being served.
func OnlyFiles ( files fs . FS ) fs . FS {
return justFilesSystem { FS : files }
}
type justFilesSystem struct {
FS fs . FS
}
func ( jfs justFilesSystem ) Open ( name string ) ( fs . File , error ) {
f , err := jfs . FS . Open ( name )
if err != nil {
return nil , err
}
stat , err := f . Stat ( )
if err != nil {
return nil , err
}
// Returning a 404 here does prevent the http.FileServer from serving
// index.* files automatically. Coder handles this above as all index pages
// are considered template files. So we never relied on this behavior.
if stat . IsDir ( ) {
return nil , os . ErrNotExist
}
return f , nil
}
2024-02-20 23:58:43 +00:00
// RenderOAuthAllowData contains the variables that are found in
// site/static/oauth2allow.html.
type RenderOAuthAllowData struct {
AppIcon string
AppName string
CancelURI string
RedirectURI string
Username string
}
// RenderOAuthAllowPage renders the static page for a user to "Allow" an create
// a new oauth2 link with an external site. This is when Coder is acting as the
// identity provider.
//
// This has to be done statically because Golang has to handle the full request.
// It cannot defer to the FE typescript easily.
func RenderOAuthAllowPage ( rw http . ResponseWriter , r * http . Request , data RenderOAuthAllowData ) {
rw . Header ( ) . Set ( "Content-Type" , "text/html; charset=utf-8" )
err := oauthTemplate . Execute ( rw , data )
if err != nil {
httpapi . Write ( r . Context ( ) , rw , http . StatusOK , codersdk . Response {
Message : "Failed to render oauth page: " + err . Error ( ) ,
} )
return
}
}