diff --git a/.gitignore b/.gitignore index 101f639..010d09a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ go.work .idea/ +.data/ diff --git a/cmd/site-hosting/main.go b/cmd/bluebell/main.go similarity index 83% rename from cmd/site-hosting/main.go rename to cmd/bluebell/main.go index fa7a8ca..74ccdb6 100644 --- a/cmd/site-hosting/main.go +++ b/cmd/bluebell/main.go @@ -4,12 +4,14 @@ import ( "context" "errors" "flag" + "github.com/1f349/bluebell" "github.com/1f349/bluebell/conf" "github.com/1f349/bluebell/logger" "github.com/1f349/bluebell/serve" "github.com/1f349/bluebell/upload" "github.com/charmbracelet/log" "github.com/cloudflare/tableflip" + "github.com/dustin/go-humanize" "github.com/julienschmidt/httprouter" "github.com/spf13/afero" "gopkg.in/yaml.v3" @@ -86,18 +88,30 @@ func main() { } }() + db, err := bluebell.InitDB(config.DB) + if err != nil { + logger.Logger.Fatal("Failed to open database", "err", err) + return + } + // Listen must be called before Ready ln, err := upg.Listen("tcp", config.Listen) if err != nil { logger.Logger.Fatal("Listen failed", "err", err) } - uploadHandler := upload.New(sitesFs) - serveHandler := serve.New(sitesFs) + uploadHandler := upload.New(sitesFs, db) + serveHandler := serve.New(sitesFs, db, config.Domain) router := httprouter.New() router.POST("/u/:site", uploadHandler.Handle) router.GET("/*filepath", serveHandler.Handle) + router.POST("/sites/:host", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + + }) + router.DELETE("/sites/:host", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { + + }) server := &http.Server{ Handler: router, @@ -105,7 +119,7 @@ func main() { ReadHeaderTimeout: 1 * time.Minute, WriteTimeout: 1 * time.Minute, IdleTimeout: 1 * time.Minute, - MaxHeaderBytes: 4_096_000, + MaxHeaderBytes: 4 * humanize.MiByte, } logger.Logger.Info("HTTP server listening on", "addr", config.Listen) go func() { diff --git a/conf/conf.go b/conf/conf.go index 51bb8c6..d3314e2 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -1,20 +1,12 @@ package conf -import "github.com/mrmelon54/trie" - type Conf struct { Listen string `yaml:"listen"` - //fs afero.Fs - //l *sync.RWMutex - m *trie.Trie[SiteConf] + DB string `yaml:"db"` + Domain string `yaml:"domain"` } -type SiteConf struct { - Domain string `json:"domain"` - Token string `json:"token"` -} - -func (c *Conf) slugFromDomain(domain string) string { +func SlugFromDomain(domain string) string { a := []byte(domain) for i := range a { switch { diff --git a/database/migrations/20240810115057_init.up.sql b/database/migrations/20240810115057_init.up.sql index 37c1223..990fd13 100644 --- a/database/migrations/20240810115057_init.up.sql +++ b/database/migrations/20240810115057_init.up.sql @@ -1,6 +1,8 @@ CREATE TABLE sites ( - id INTEGER NOT NULL PRIMARY KEY, + id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + slug TEXT(8) NOT NULL, domain TEXT NOT NULL, - token TEXT NOT NULL + token TEXT NOT NULL, + enable BOOL NOT NULL ); diff --git a/database/models.go b/database/models.go index 7645aec..06cc095 100644 --- a/database/models.go +++ b/database/models.go @@ -8,6 +8,8 @@ import () type Site struct { ID int32 `json:"id"` + Slug string `json:"slug"` Domain string `json:"domain"` Token string `json:"token"` + Enable bool `json:"enable"` } diff --git a/database/queries/sites.sql b/database/queries/sites.sql index 4811f57..9e06154 100644 --- a/database/queries/sites.sql +++ b/database/queries/sites.sql @@ -1,5 +1,20 @@ +-- name: GetSiteBySlug :one +SELECT * +FROM sites +WHERE slug = ? +LIMIT 1; + -- name: GetSiteByDomain :one SELECT * FROM sites WHERE domain = ? LIMIT 1; + +-- name: EnableDomain :exec +INSERT INTO sites (slug, domain, token) +VALUES (?, ?, ?); + +-- name: DeleteDomain :exec +UPDATE sites +SET enable = false +WHERE domain = ?; diff --git a/database/sites.sql.go b/database/sites.sql.go index ed3018b..1fc97aa 100644 --- a/database/sites.sql.go +++ b/database/sites.sql.go @@ -9,8 +9,35 @@ import ( "context" ) +const deleteDomain = `-- name: DeleteDomain :exec +UPDATE sites +SET enable = false +WHERE domain = ? +` + +func (q *Queries) DeleteDomain(ctx context.Context, domain string) error { + _, err := q.db.ExecContext(ctx, deleteDomain, domain) + return err +} + +const enableDomain = `-- name: EnableDomain :exec +INSERT INTO sites (slug, domain, token) +VALUES (?, ?, ?) +` + +type EnableDomainParams struct { + Slug string `json:"slug"` + Domain string `json:"domain"` + Token string `json:"token"` +} + +func (q *Queries) EnableDomain(ctx context.Context, arg EnableDomainParams) error { + _, err := q.db.ExecContext(ctx, enableDomain, arg.Slug, arg.Domain, arg.Token) + return err +} + const getSiteByDomain = `-- name: GetSiteByDomain :one -SELECT id, domain, token +SELECT id, slug, domain, token, enable FROM sites WHERE domain = ? LIMIT 1 @@ -19,6 +46,32 @@ LIMIT 1 func (q *Queries) GetSiteByDomain(ctx context.Context, domain string) (Site, error) { row := q.db.QueryRowContext(ctx, getSiteByDomain, domain) var i Site - err := row.Scan(&i.ID, &i.Domain, &i.Token) + err := row.Scan( + &i.ID, + &i.Slug, + &i.Domain, + &i.Token, + &i.Enable, + ) + return i, err +} + +const getSiteBySlug = `-- name: GetSiteBySlug :one +SELECT id, slug, domain, token, enable +FROM sites +WHERE slug = ? +LIMIT 1 +` + +func (q *Queries) GetSiteBySlug(ctx context.Context, slug string) (Site, error) { + row := q.db.QueryRowContext(ctx, getSiteBySlug, slug) + var i Site + err := row.Scan( + &i.ID, + &i.Slug, + &i.Domain, + &i.Token, + &i.Enable, + ) return i, err } diff --git a/go.mod b/go.mod index 418ecc1..d6a858e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.4 require ( github.com/charmbracelet/log v0.4.0 github.com/cloudflare/tableflip v1.2.3 + github.com/dustin/go-humanize v1.0.1 github.com/golang-migrate/migrate/v4 v4.18.1 github.com/julienschmidt/httprouter v1.3.0 github.com/mrmelon54/trie v0.0.3 diff --git a/go.sum b/go.sum index 3745120..ae0843a 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= diff --git a/initdb.go b/initdb.go index bf005dd..0d7d96e 100644 --- a/initdb.go +++ b/initdb.go @@ -5,6 +5,7 @@ import ( "embed" "errors" "github.com/1f349/bluebell/database" + "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/mysql" "github.com/golang-migrate/migrate/v4/source/iofs" ) diff --git a/serve/serve.go b/serve/serve.go index 49f049c..d16d843 100644 --- a/serve/serve.go +++ b/serve/serve.go @@ -1,10 +1,13 @@ package serve import ( + "context" "github.com/1f349/bluebell/conf" + "github.com/1f349/bluebell/database" "github.com/julienschmidt/httprouter" "github.com/spf13/afero" "io" + "net" "net/http" "os" "path" @@ -24,78 +27,59 @@ var ( } ) -func New(config conf.Conf, storage afero.Fs) *Handler { - return &Handler{config, storage} +type sitesQueries interface { + GetSiteByDomain(ctx context.Context, domain string) (database.Site, error) +} + +func New(storage afero.Fs, db sitesQueries, domain string) *Handler { + return &Handler{storage, db, domain} } type Handler struct { - conf conf.Conf storageFs afero.Fs + db sitesQueries + domain string } -func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { - site, branch, subdomain, ok := h.findSiteBranchSubdomain(req.Host) +func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { + host, _, err := net.SplitHostPort(req.Host) + if err != nil { + http.Error(rw, "Bad Gateway", http.StatusBadGateway) + return + } + site, ok := strings.CutSuffix(host, "."+h.domain) if !ok { http.Error(rw, "Bad Gateway", http.StatusBadGateway) return } + site = conf.SlugFromDomain(site) + branch := req.URL.User.Username() if branch == "" { for _, i := range indexBranches { - if h.tryServePath(rw, site, i, subdomain, req.URL.Path) { + if h.tryServePath(rw, site, i, req.URL.Path) { return } } - } else if h.tryServePath(rw, site, branch, subdomain, req.URL.Path) { + } else if h.tryServePath(rw, site, branch, req.URL.Path) { return } http.Error(rw, "404 Not Found", http.StatusNotFound) } -func (h *Handler) findSiteBranchSubdomain(host string) (site, branch, subdomain string, ok bool) { - var siteN int - siteN, site = h.findSite(host) - if site == "" { - return - } - - if host[siteN] != '-' { - return - } - host = host[siteN+1:] - - strings.LastIndexByte(host, '-') - return -} - -func (h *Handler) findSite(host string) (int, string) { - siteVal, siteN, siteOk := h.conf.Get(host) - if !siteOk || siteVal == nil { - return -1, "" - } - - // so I used less than or equal here that's to prevent a bug where the prefix - // found is longer than the string obviously that sounds impossible, and it is, - // but I would rather the program not crash if some other bug allows this weird - // event to happen - if siteN <= len(host) { - return -1, "" - } - return siteN, siteVal.Domain -} - -func (h *Handler) tryServePath(rw http.ResponseWriter, site, branch, subdomain, p string) bool { +func (h *Handler) tryServePath(rw http.ResponseWriter, site, branch, p string) bool { for _, i := range indexFiles { - if h.tryServeFile(rw, site, branch, subdomain, i(p)) { + if h.tryServeFile(rw, site, branch, i(p)) { return true } } return false } -func (h *Handler) tryServeFile(rw http.ResponseWriter, site, branch, subdomain, p string) bool { - // if there is a subdomain then load files from inside the subdomain folder - if subdomain != "" { - p = filepath.Join("_subdomain", subdomain, p) +func (h *Handler) tryServeFile(rw http.ResponseWriter, site, branch, p string) bool { + // prevent path traversal + if strings.Contains(site, "..") || strings.Contains(branch, "..") || strings.Contains(p, "..") { + http.Error(rw, "400 Bad Request", http.StatusBadRequest) + return true } open, err := h.storageFs.Open(filepath.Join(site, branch, p)) switch { diff --git a/upload/upload.go b/upload/upload.go index 3212b95..49a4013 100644 --- a/upload/upload.go +++ b/upload/upload.go @@ -3,8 +3,10 @@ package upload import ( "archive/tar" "compress/gzip" + "context" "fmt" - "github.com/1f349/bluebell/conf" + "github.com/1f349/bluebell/database" + "github.com/dustin/go-humanize" "github.com/julienschmidt/httprouter" "github.com/spf13/afero" "io" @@ -12,25 +14,35 @@ import ( "net/http" "os" "path/filepath" + "strings" ) -func New(storage afero.Fs) *Handler { - return &Handler{storage, conf} +type sitesQueries interface { + GetSiteBySlug(ctx context.Context, slug string) (database.Site, error) + GetSiteByDomain(ctx context.Context, domain string) (database.Site, error) } +func New(storage afero.Fs, db sitesQueries) *Handler { + return &Handler{storage, db} +} + +const maxFileSize = 1 * humanize.GiByte + type Handler struct { storageFs afero.Fs - conf *conf.Conf + db sitesQueries } -func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, params httprouter.Params) { +func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { q := req.URL.Query() site := q.Get("site") branch := q.Get("branch") - siteConf, siteN, siteOk := h.conf.Get(site) - if !siteOk || siteN != len(site) || siteConf == nil { - http.Error(rw, "400 Bad Request", http.StatusBadRequest) + site = strings.ReplaceAll(site, "*", "") + + siteConf, err := h.db.GetSiteByDomain(req.Context(), "*"+site) + if err != nil { + http.Error(rw, "", http.StatusNotFound) return } if "Bearer "+siteConf.Token != req.Header.Get("Authorization") { @@ -45,7 +57,7 @@ func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, params httpr } // if file is bigger than 1GiB - if fileHeader.Size > 1074000000 { + if fileHeader.Size > maxFileSize { http.Error(rw, "File too big", http.StatusBadRequest) return }