This is a somewhat decent state of the source code

This commit is contained in:
Melon 2025-01-05 18:41:38 +00:00
parent d8ccd87e9b
commit 27be538a06
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
19 changed files with 421 additions and 341 deletions

View File

@ -88,24 +88,28 @@ func main() {
}
}()
db, err := bluebell.InitDB(config.DB)
db, err := bluebell.InitDB(filepath.Join(wd, 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)
lnHttp, err := upg.Listen("tcp", config.Listen.Http)
if err != nil {
logger.Logger.Fatal("Listen failed", "err", err)
}
lnApi, err := upg.Listen("tcp", config.Listen.Api)
if err != nil {
logger.Logger.Fatal("Listen failed", "err", err)
}
uploadHandler := upload.New(sitesFs, db)
serveHandler := serve.New(sitesFs, db, config.Domain)
serveHandler := serve.New(sitesFs, db)
router := httprouter.New()
router.POST("/u/:site", uploadHandler.Handle)
router.GET("/*filepath", serveHandler.Handle)
router.POST("/u/:site/:branch", uploadHandler.Handle)
router.POST("/sites/:host", func(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
})
@ -113,7 +117,23 @@ func main() {
})
server := &http.Server{
serverHttp := &http.Server{
Handler: serveHandler,
ReadTimeout: 1 * time.Minute,
ReadHeaderTimeout: 1 * time.Minute,
WriteTimeout: 1 * time.Minute,
IdleTimeout: 1 * time.Minute,
MaxHeaderBytes: 4 * humanize.MiByte,
}
logger.Logger.Info("HTTP server listening on", "addr", config.Listen.Http)
go func() {
err := serverHttp.Serve(lnHttp)
if !errors.Is(err, http.ErrServerClosed) {
logger.Logger.Fatal("Serve failed", "err", err)
}
}()
serverApi := &http.Server{
Handler: router,
ReadTimeout: 1 * time.Minute,
ReadHeaderTimeout: 1 * time.Minute,
@ -121,11 +141,11 @@ func main() {
IdleTimeout: 1 * time.Minute,
MaxHeaderBytes: 4 * humanize.MiByte,
}
logger.Logger.Info("HTTP server listening on", "addr", config.Listen)
logger.Logger.Info("API server listening on", "addr", config.Listen.Api)
go func() {
err := server.Serve(ln)
err := serverApi.Serve(lnApi)
if !errors.Is(err, http.ErrServerClosed) {
logger.Logger.Fatal("Serve failed", "err", err)
logger.Logger.Fatal("API Serve failed", "err", err)
}
}()
@ -140,5 +160,5 @@ func main() {
os.Exit(1)
})
server.Shutdown(context.Background())
serverHttp.Shutdown(context.Background())
}

View File

@ -1,26 +1,11 @@
package conf
type Conf struct {
Listen string `yaml:"listen"`
DB string `yaml:"db"`
Domain string `yaml:"domain"`
Listen ListenConf `yaml:"listen"`
DB string `yaml:"db"`
}
func SlugFromDomain(domain string) string {
a := []byte(domain)
for i := range a {
switch {
case a[i] == '-':
// skip
case a[i] >= 'A' && a[i] <= 'Z':
a[i] += 32
case a[i] >= 'a' && a[i] <= 'z':
// skip
case a[i] >= '0' && a[i] <= '9':
// skip
default:
a[i] = '-'
}
}
return string(a)
type ListenConf struct {
Http string `yaml:"http"`
Api string `yaml:"api"`
}

View File

@ -1,70 +0,0 @@
package conf
import (
_ "embed"
"github.com/mrmelon54/trie"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"sync"
"testing"
)
//go:embed test-sites.yml
var testSitesYml []byte
func TestConfig_Load(t *testing.T) {
f := afero.NewMemMapFs()
create, err := f.Create("sites.yml")
assert.NoError(t, err)
_, err = create.Write(testSitesYml)
assert.NoError(t, err)
assert.NoError(t, create.Close())
c := New(f)
assert.NoError(t, c.Load())
val, ok := c.m.GetByString("example-com")
assert.True(t, ok)
assert.Equal(t, SiteConf{Domain: "example.com", Token: "abcd1234"}, *val)
}
func TestConfig_loadSlice(t *testing.T) {
c := &Conf{l: new(sync.RWMutex)}
c.loadSlice([]SiteConf{
{Domain: "example.com", Token: "abcd1234"},
})
a, ok := c.m.GetByString("example-com")
assert.True(t, ok)
assert.Equal(t, SiteConf{Domain: "example.com", Token: "abcd1234"}, *a)
}
func TestConfig_slugFromDomain(t *testing.T) {
c := &Conf{}
assert.Equal(t, "---------------", c.slugFromDomain("!\"#$%&'()*+,-./"))
assert.Equal(t, "0123456789", c.slugFromDomain("0123456789"))
assert.Equal(t, "-------", c.slugFromDomain(":;<=>?@"))
assert.Equal(t, "abcdefghijklmnopqrstuvwxyz", c.slugFromDomain("ABCDEFGHIJKLMNOPQRSTUVWXYZ"))
assert.Equal(t, "------", c.slugFromDomain("[\\]^_`"))
assert.Equal(t, "abcdefghijklmnopqrstuvwxyz", c.slugFromDomain("abcdefghijklmnopqrstuvwxyz"))
assert.Equal(t, "----", c.slugFromDomain("{|}~"))
}
func FuzzConfig_slugFromDomain(f *testing.F) {
c := &Conf{}
f.Fuzz(func(t *testing.T, a string) {
b := c.slugFromDomain(a)
if len(a) != len(b) {
t.Fatalf("value '%s' (%d) did not match lengths with the output '%s' (%d)", a, len(a), b, len(b))
}
})
}
func TestConfig_Get(t *testing.T) {
c := &Conf{l: new(sync.RWMutex), m: &trie.Trie[SiteConf]{}}
c.loadSlice([]SiteConf{
{Domain: "example.com", Token: "abcd1234"},
})
val, n, ok := c.Get("example.com")
assert.True(t, ok)
assert.Equal(t, 11, n)
assert.Equal(t, SiteConf{Domain: "example.com", Token: "abcd1234"}, *val)
}

View File

@ -1,2 +0,0 @@
- domain: example.com
token: abcd1234

View File

@ -1,8 +1,15 @@
CREATE TABLE sites
(
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
slug TEXT(8) NOT NULL,
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
token TEXT NOT NULL,
enable BOOL NOT NULL
token TEXT NOT NULL
);
CREATE TABLE branches
(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL,
branch TEXT NOT NULL,
last_update DATETIME NOT NULL,
enable BOOLEAN NOT NULL
);

View File

@ -4,12 +4,20 @@
package database
import ()
import (
"time"
)
type Branch struct {
ID int64 `json:"id"`
Domain string `json:"domain"`
Branch string `json:"branch"`
LastUpdate time.Time `json:"last_update"`
Enable bool `json:"enable"`
}
type Site struct {
ID int32 `json:"id"`
Slug string `json:"slug"`
ID int64 `json:"id"`
Domain string `json:"domain"`
Token string `json:"token"`
Enable bool `json:"enable"`
}

View File

@ -1,20 +1,23 @@
-- 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: GetLastUpdatedByDomainBranch :one
SELECT last_update
FROM branches
WHERE domain = ?
AND branch = ?
AND enable = true
LIMIT 1;
-- name: DeleteDomain :exec
UPDATE sites
SET enable = false
WHERE domain = ?;
-- name: AddSiteDomain :exec
INSERT INTO sites (domain, token)
VALUES (?, ?);
-- name: SetDomainBranchEnabled :exec
UPDATE branches
SET enable = ?
WHERE domain = ?
AND branch = ?;

View File

@ -7,37 +7,47 @@ package database
import (
"context"
"time"
)
const deleteDomain = `-- name: DeleteDomain :exec
UPDATE sites
SET enable = false
WHERE domain = ?
const addSiteDomain = `-- name: AddSiteDomain :exec
INSERT INTO sites (domain, token)
VALUES (?, ?)
`
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"`
type AddSiteDomainParams struct {
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)
func (q *Queries) AddSiteDomain(ctx context.Context, arg AddSiteDomainParams) error {
_, err := q.db.ExecContext(ctx, addSiteDomain, arg.Domain, arg.Token)
return err
}
const getLastUpdatedByDomainBranch = `-- name: GetLastUpdatedByDomainBranch :one
SELECT last_update
FROM branches
WHERE domain = ?
AND branch = ?
AND enable = true
LIMIT 1
`
type GetLastUpdatedByDomainBranchParams struct {
Domain string `json:"domain"`
Branch string `json:"branch"`
}
func (q *Queries) GetLastUpdatedByDomainBranch(ctx context.Context, arg GetLastUpdatedByDomainBranchParams) (time.Time, error) {
row := q.db.QueryRowContext(ctx, getLastUpdatedByDomainBranch, arg.Domain, arg.Branch)
var last_update time.Time
err := row.Scan(&last_update)
return last_update, err
}
const getSiteByDomain = `-- name: GetSiteByDomain :one
SELECT id, slug, domain, token, enable
SELECT id, domain, token
FROM sites
WHERE domain = ?
LIMIT 1
@ -46,32 +56,24 @@ 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.Slug,
&i.Domain,
&i.Token,
&i.Enable,
)
err := row.Scan(&i.ID, &i.Domain, &i.Token)
return i, err
}
const getSiteBySlug = `-- name: GetSiteBySlug :one
SELECT id, slug, domain, token, enable
FROM sites
WHERE slug = ?
LIMIT 1
const setDomainBranchEnabled = `-- name: SetDomainBranchEnabled :exec
UPDATE branches
SET enable = ?
WHERE domain = ?
AND branch = ?
`
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
type SetDomainBranchEnabledParams struct {
Enable bool `json:"enable"`
Domain string `json:"domain"`
Branch string `json:"branch"`
}
func (q *Queries) SetDomainBranchEnabled(ctx context.Context, arg SetDomainBranchEnabledParams) error {
_, err := q.db.ExecContext(ctx, setDomainBranchEnabled, arg.Enable, arg.Domain, arg.Branch)
return err
}

10
go.mod
View File

@ -8,30 +8,28 @@ require (
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
github.com/spf13/afero v1.11.0
github.com/stretchr/testify v1.9.0
github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v1.0.0 // indirect
github.com/charmbracelet/x/ansi v0.6.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

62
go.sum
View File

@ -1,9 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
@ -16,30 +10,10 @@ github.com/cloudflare/tableflip v1.2.3 h1:8I+B99QnnEWPHOY3fWipwVKxS70LGgUsslG7CS
github.com/cloudflare/tableflip v1.2.3/go.mod h1:P4gRehmV6Z2bY5ao5ml9Pd8u6kuEnlB37pUFMmv7j2E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.3 h1:wquqUxAFdcUgabAVLvSCOKOlag5cIZuaOjYIBOWdsR0=
github.com/dhui/dktest v0.4.3/go.mod h1:zNK8IwktWzQRm6I/l2Wjp7MakiyaFWv4G1hjmodmMTs=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
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=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -57,22 +31,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mrmelon54/trie v0.0.3 h1:wZmws84FiGNBZJ00garLyQ2EQhtx0SipVoV7fK8+kZE=
github.com/mrmelon54/trie v0.0.3/go.mod h1:d3hl0YUBSWR3XN4S9BDLkGVzLT4VgwP2mZkBJM6uFpw=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@ -80,24 +42,16 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588=
golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

BIN
identifier.sqlite Normal file

Binary file not shown.

View File

@ -6,7 +6,7 @@ import (
"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/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
@ -18,15 +18,15 @@ func InitDB(p string) (*database.Queries, error) {
if err != nil {
return nil, err
}
dbOpen, err := sql.Open("mysql", p)
dbOpen, err := sql.Open("sqlite3", p)
if err != nil {
return nil, err
}
dbDrv, err := mysql.WithInstance(dbOpen, &mysql.Config{})
dbDrv, err := sqlite3.WithInstance(dbOpen, &sqlite3.Config{})
if err != nil {
return nil, err
}
mig, err := migrate.NewWithInstance("iofs", migDrv, "mysql", dbDrv)
mig, err := migrate.NewWithInstance("iofs", migDrv, "sqlite3", dbDrv)
if err != nil {
return nil, err
}

View File

@ -0,0 +1,11 @@
<html lang="en">
<head><title>{{.Host}}</title></head>
<body>
<center>
<h1>{{.Host}}</h1>
<p>The requested beta is not available</p>
<hr>
<a href="/__bluebell-switch-beta?reset">Revert to main website</a>
</center>
</body>
</html>

View File

@ -2,90 +2,172 @@ package serve
import (
"context"
"github.com/1f349/bluebell/conf"
_ "embed"
"github.com/1f349/bluebell/database"
"github.com/julienschmidt/httprouter"
"github.com/1f349/bluebell/logger"
"github.com/spf13/afero"
"io"
"html/template"
"net"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
var (
indexBranches = []string{
"main",
"master",
}
//go:embed missing-branch.go.html
missingBranchHtml string
missingBranchTemplate = template.Must(template.New("missingBranchHtml").Parse(missingBranchHtml))
indexFiles = []func(p string) string{
func(p string) string { return path.Join(p, "index.html") },
func(p string) string { return p + ".html" },
func(p string) string { return p },
func(p string) string { return p + ".html" },
func(p string) string { return path.Join(p, "index.html") },
}
)
type sitesQueries interface {
GetSiteByDomain(ctx context.Context, domain string) (database.Site, error)
func isInvalidIndexPath(p string) bool {
switch p {
case ".", ".html":
return true
}
return false
}
func New(storage afero.Fs, db sitesQueries, domain string) *Handler {
return &Handler{storage, db, domain}
const (
BetaCookieName = "__bluebell-site-beta"
BetaSwitchPath = "/__bluebell-switch-beta"
BetaExpiry = 24 * time.Hour
NoCacheQuery = "/?__bluebell-no-cache="
)
type sitesQueries interface {
GetLastUpdatedByDomainBranch(ctx context.Context, params database.GetLastUpdatedByDomainBranchParams) (time.Time, error)
}
func New(storage afero.Fs, db sitesQueries) *Handler {
return &Handler{storage, db}
}
type Handler struct {
storageFs afero.Fs
db sitesQueries
domain string
}
func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
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) {
host, _, err := net.SplitHostPort(req.Host)
if err != nil {
http.Error(rw, "Bad Gateway", http.StatusBadGateway)
return
host = req.Host
}
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, req.URL.Path) {
return
}
// 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,
}
} else if h.tryServePath(rw, site, branch, req.URL.Path) {
// 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)
return
}
// 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)
return
}
if h.tryServePath(rw, req, host, branch, updated, req.URL.Path) {
return // page has been served
}
// tryServePath found no matching files
http.Error(rw, "404 Not Found", http.StatusNotFound)
logger.Logger.Debug("No matching file was found")
}
func (h *Handler) tryServePath(rw http.ResponseWriter, site, branch, p string) bool {
// 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 {
for _, i := range indexFiles {
if h.tryServeFile(rw, site, branch, i(p)) {
// skip invalid paths "." and ".html"
p2 := path.Clean(i(p))
if isInvalidIndexPath(p2) {
continue
}
if h.tryServeFile(rw, req, site, branch, updated, p2) {
return true
}
}
return false
}
func (h *Handler) tryServeFile(rw http.ResponseWriter, site, branch, p string) bool {
// 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 {
// 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))
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)
switch {
case err == nil:
rw.WriteHeader(http.StatusOK)
_, _ = io.Copy(rw, open)
// 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)
case os.IsNotExist(err):
// check next path
return false

View File

@ -1,33 +1,73 @@
package serve
import (
"github.com/1f349/bluebell/conf"
"context"
"database/sql"
"fmt"
"github.com/1f349/bluebell/database"
"github.com/1f349/bluebell/logger"
"github.com/charmbracelet/log"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func makeConfig(f afero.Fs) (*conf.Conf, error) {
c := conf.New(f)
return c, c.Load()
func init() {
logger.Logger.SetLevel(log.DebugLevel)
}
func TestName(t *testing.T) {
f := afero.NewMemMapFs()
h := &Handler{
storageFs: f,
conf: conf.Testable([]conf.SiteConf{
{Domain: "example.com", Token: "abcd1234"},
}),
}
h.findSiteBranchSubdomain("example-com-test")
site, branch := h.findSiteBranch("example-com_test")
type fakeServeDB struct {
branch string
}
func TestHandler_Handle(t *testing.T) {
f := afero.NewMemMapFs()
h := &Handler{
storageFs: f,
conf: &conf.Conf{},
func (f *fakeServeDB) GetLastUpdatedByDomainBranch(_ context.Context, params database.GetLastUpdatedByDomainBranchParams) (time.Time, error) {
if params.Domain == "example.com" && params.Branch == "@"+f.branch {
return time.Now(), nil
}
h.Handle()
return time.Time{}, sql.ErrNoRows
}
func TestHandler_ServeHTTP(t *testing.T) {
for _, branch := range []string{"", "test", "dev"} {
t.Run(branch+" branch", func(t *testing.T) {
serveTest(t, "example.com", branch, "example.com/@"+branch+"/index.html")
serveTest(t, "example.com/hello-world", branch, "example.com/@"+branch+"/hello-world.html")
serveTest(t, "example.com/hello-world", branch, "example.com/@"+branch+"/hello-world/index.html")
serveTest(t, "example.com/hello-world", branch, "example.com/@"+branch+"/hello-world")
})
}
}
func serveTest(t *testing.T, address string, branch string, name string) {
t.Run(fmt.Sprintf("serveTest \"%s\" (%s) -> \"%s\"", address, branch, name), func(t *testing.T) {
fs := afero.NewMemMapFs()
assert.NoError(t, fs.MkdirAll(filepath.Dir(name), os.ModePerm))
assert.NoError(t, afero.WriteFile(fs, name, []byte("Hello World\n"), 0666))
h := New(fs, &fakeServeDB{branch: branch})
//goland:noinspection HttpUrlsUsage
const httpPrefix = "http://"
req := httptest.NewRequest(http.MethodPost, httpPrefix+address, nil)
if branch != "" {
req.AddCookie(&http.Cookie{
Name: "__bluebell-site-beta",
Value: branch,
})
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
res := rec.Result()
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.NotNil(t, res.Body)
all, err := io.ReadAll(res.Body)
assert.NoError(t, err)
assert.Equal(t, "Hello World\n", string(all))
})
}

View File

@ -1,6 +1,6 @@
version: "2"
sql:
- engine: mysql
- engine: sqlite
queries: database/queries
schema: database/migrations
gen:

View File

@ -1,2 +0,0 @@
- domain: example.com
token: abcd1234

View File

@ -4,6 +4,8 @@ import (
"archive/tar"
"compress/gzip"
"context"
"crypto/subtle"
"errors"
"fmt"
"github.com/1f349/bluebell/database"
"github.com/dustin/go-humanize"
@ -12,13 +14,17 @@ import (
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
)
var indexBranches = []string{
"main",
"master",
}
type sitesQueries interface {
GetSiteBySlug(ctx context.Context, slug string) (database.Site, error)
GetSiteByDomain(ctx context.Context, domain string) (database.Site, error)
}
@ -33,19 +39,19 @@ type Handler struct {
db sitesQueries
}
func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) {
q := req.URL.Query()
site := q.Get("site")
branch := q.Get("branch")
func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, params httprouter.Params) {
site := params.ByName("site")
branch := params.ByName("branch")
site = strings.ReplaceAll(site, "*", "")
siteConf, err := h.db.GetSiteByDomain(req.Context(), "*"+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") {
token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(siteConf.Token)) == 0 {
http.Error(rw, "403 Forbidden", http.StatusForbidden)
return
}
@ -58,7 +64,7 @@ func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, _ httprouter
// if file is bigger than 1GiB
if fileHeader.Size > maxFileSize {
http.Error(rw, "File too big", http.StatusBadRequest)
http.Error(rw, "File too big", http.StatusInsufficientStorage)
return
}
@ -72,9 +78,18 @@ func (h *Handler) Handle(rw http.ResponseWriter, req *http.Request, _ httprouter
}
func (h *Handler) extractTarGzUpload(fileData io.Reader, site, branch string) error {
siteBranchPath := filepath.Join(site, branch)
err := h.storageFs.Rename(siteBranchPath, siteBranchPath+".old")
if err != nil && !os.IsNotExist(err) {
if slices.Contains(indexBranches, branch) {
branch = ""
}
siteBranchPath := filepath.Join(site, "@"+branch)
err := h.storageFs.RemoveAll(siteBranchPath + ".old")
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("failed to remove old site branch %s: %w", siteBranchPath, err)
}
err = h.storageFs.Rename(siteBranchPath, siteBranchPath+".old")
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("failed to save an old copy of the site: %w", err)
}

View File

@ -2,8 +2,11 @@ package upload
import (
"bytes"
"context"
"database/sql"
_ "embed"
"github.com/1f349/bluebell/conf"
"fmt"
"github.com/1f349/bluebell/database"
"github.com/julienschmidt/httprouter"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
@ -17,65 +20,91 @@ import (
var (
//go:embed test-archive.tar.gz
testArchiveTarGz []byte
//go:embed test-sites.yml
testSitesYml []byte
)
func assertUploadedFile(t *testing.T, fs afero.Fs) {
func assertUploadedFile(t *testing.T, fs afero.Fs, branch string) {
switch branch {
case "main", "master":
branch = ""
}
// check uploaded file exists
stat, err := fs.Stat("example.com/main/test.txt")
stat, err := fs.Stat("example.com/@" + branch + "/test.txt")
assert.NoError(t, err)
assert.False(t, stat.IsDir())
assert.Equal(t, int64(13), stat.Size())
// check contents
o, err := fs.Open("example.com/main/test.txt")
o, err := fs.Open("example.com/@" + branch + "/test.txt")
assert.NoError(t, err)
all, err := io.ReadAll(o)
assert.NoError(t, err)
assert.Equal(t, "Hello world!\n", string(all))
}
type fakeUploadDB struct {
}
func (f *fakeUploadDB) GetSiteByDomain(_ context.Context, domain string) (database.Site, error) {
if domain == "example.com" {
return database.Site{
ID: 1,
Domain: "example.com",
Token: "abcd1234",
}, nil
}
return database.Site{}, sql.ErrNoRows
}
func TestHandler_Handle(t *testing.T) {
f := afero.NewMemMapFs()
conf := conf.New(f)
h := &Handler{f, conf}
create, err := f.Create("sites.yml")
assert.NoError(t, err)
_, err = create.Write(testSitesYml)
assert.NoError(t, err)
assert.NoError(t, create.Close())
assert.NoError(t, conf.Load())
fs := afero.NewMemMapFs()
h := New(fs, new(fakeUploadDB))
mpBuf := new(bytes.Buffer)
mp := multipart.NewWriter(mpBuf)
file, err := mp.CreateFormFile("upload", "test-archive.tar.gz")
assert.NoError(t, err)
_, err = file.Write(testArchiveTarGz)
assert.NoError(t, err)
assert.NoError(t, mp.Close())
req, err := http.NewRequest(http.MethodPost, "https://example.com/u?site=example.com&branch=main", mpBuf)
assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer abcd1234")
req.Header.Set("Content-Type", mp.FormDataContentType())
rec := httptest.NewRecorder()
h.Handle(rec, req, httprouter.Params{})
res := rec.Result()
assert.Equal(t, http.StatusAccepted, res.StatusCode)
assert.NotNil(t, res.Body)
all, err := io.ReadAll(res.Body)
assert.NoError(t, err)
assert.Equal(t, "", string(all))
r := httprouter.New()
r.POST("/u/:site/:branch", h.Handle)
r.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("Not Found")
})
assertUploadedFile(t, f)
for _, branch := range []string{"main", "test", "dev"} {
t.Run(branch+" branch", func(t *testing.T) {
mpBuf := new(bytes.Buffer)
mp := multipart.NewWriter(mpBuf)
file, err := mp.CreateFormFile("upload", "test-archive.tar.gz")
assert.NoError(t, err)
_, err = file.Write(testArchiveTarGz)
assert.NoError(t, err)
assert.NoError(t, mp.Close())
req := httptest.NewRequest(http.MethodPost, "https://example.com/u/example.com/"+branch, mpBuf)
req.Header.Set("Authorization", "Bearer abcd1234")
req.Header.Set("Content-Type", mp.FormDataContentType())
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
res := rec.Result()
assert.Equal(t, http.StatusAccepted, res.StatusCode)
assert.NotNil(t, res.Body)
all, err := io.ReadAll(res.Body)
assert.NoError(t, err)
assert.Equal(t, "", string(all))
fmt.Println(fs)
assertUploadedFile(t, fs, branch)
})
}
}
func TestHandler_extractTarGzUpload(t *testing.T) {
fs := afero.NewMemMapFs()
conf := conf.New(fs)
h := &Handler{fs, conf}
buffer := bytes.NewBuffer(testArchiveTarGz)
assert.NoError(t, h.extractTarGzUpload(buffer, "example.com", "main"))
for _, branch := range []string{"main", "test", "dev"} {
t.Run(branch+" branch", func(t *testing.T) {
fs := afero.NewMemMapFs()
h := New(fs, nil)
buffer := bytes.NewBuffer(testArchiveTarGz)
assert.NoError(t, h.extractTarGzUpload(buffer, "example.com", branch))
assertUploadedFile(t, fs)
assertUploadedFile(t, fs, branch)
})
}
}