M README.md => README.md +2 -0
@@ 27,6 27,8 @@ go build
## Self-hosting
+Tagged versions should be stable. Main branch is not guaranteed to be.
+
FishBB is designed to require a minimal amount of infrastructure and
maintenance burden for self-hosting. Please reach out to me [alex@alexwennerberg.com](mailto:alex@alexwennerberg.com) if you are interested in running your own instance!
M main.go => main.go +68 -1
@@ 1,9 1,76 @@
package main
import (
+ "context"
+ "database/sql"
+ "net/http"
+ "strings"
+
"git.sr.ht/~aw/fishbb/server"
+ fishbb "git.sr.ht/~aw/fishbb/server"
+ // "github.com/go-chi/chi"
)
+const instanceSubdomain = ""
+
+// TODO parameterize
+const dbFolder = "dbs"
+
+// multi-instance main
func main() {
- server.Serve()
+ fishbb.Serve()
+ // initDBs()
+ // TODO ...
+ // r := chi.NewRouter()
+ // No Subdomain -> cluster routes
+ // Has subdomain -> instance route
+}
+
+func indexPage(w http.ResponseWriter, r *http.Request) {
+ // cache me
+ // Iterate over DBs
+ // execute select * query
+}
+
+func createInstancePage(w http.ResponseWriter, r *http.Request) {
+ // render html page with form to create an instance
+}
+
+func createInstanceHandler() {
+ // var instanceName string
+ // var adminUser string
+ // var adminPassword string
+ // legal == alphanumeric and - (ascii domain)
+ // check if file exists
+ // make db
+ // initialize admin user
+}
+
+// DB context middleware
+func DBContext(next http.Handler) http.Handler {
+ fn := func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ subdomain, _, _ := strings.Cut(r.Host, ".")
+ d, ok := databases[subdomain]
+ if !ok {
+ w.WriteHeader(404)
+ w.Write([]byte("404 not found"))
+ return
+ }
+ ctx = context.WithValue(ctx, "db", d)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ }
+ return http.HandlerFunc(fn)
+}
+
+var databases map[string]*sql.DB
+var configs map[string]server.Config
+
+func initDBs() {
+ // items, _ := ioutil.ReadDir(dbFolder)
+ // for _, _ := range items {
+ // Open DB
+ //...
+ // fishbb.PrepareStatements(db)
+ // }
}
M server/config.go => server/config.go +39 -38
@@ 1,27 1,26 @@
package server
-import (
- "bytes"
-
- "github.com/BurntSushi/toml"
-)
+import "strconv"
// non user-configurable config
var Port = ":8080"
var DBPath = "fishbb.db"
+// Changing this will break existing URLs
+const PageSize int = 50
+
+// TODO -- start gating features on self hosted or not
+var SingleInstance = false
+
// most of these don't work yet
type Config struct {
// Whether new signups require admin approval before users can post
RequiresApproval bool
- // The title of the bulletin board
+ // The title of the bulletin board (NOT CONFIGURABLE)
BoardName string
// The description of the bulletin board
BoardDescription string
- // The size of pages on threads and forums
- PageSize int
-
// optional (for oauth)
Domain string // todo not exactly
GoogleOAuthClientID string
@@ 32,20 31,15 @@ type Config struct {
SMTPPassword string
}
-func (c Config) TOMLString() string {
- var b bytes.Buffer
- err := toml.NewEncoder(&b).Encode(c)
- if err != nil {
- panic(err) // TODO
- }
- return b.String()
+// in multi-instance, config values that are shared by the cluster
+// TODO
+type SharedConfig struct {
}
func DefaultConfig() Config {
return Config{
BoardName: "fishbb",
BoardDescription: "A discussion board",
- PageSize: 100,
RequiresApproval: true,
Domain: "http://localhost:8080",
GoogleOAuthClientID: "",
@@ 53,34 47,41 @@ func DefaultConfig() Config {
}
}
-func GetConfig(key string) (Config, error) {
+func SaveConfig(c Config) error {
+ UpdateConfig("board-name", c.BoardName)
+ UpdateConfig("board-description", c.BoardDescription)
+ UpdateConfig("requires-approval", c.RequiresApproval)
+ return nil
+}
+
+// get all config values TODO cleanup
+func GetConfig() (Config, error) {
+ var c Config
+ // TODO cleanup
+ c.BoardDescription, _ = GetConfigValue("board-description")
+ c.BoardName, _ = GetConfigValue("board-description")
+ if SingleInstance {
+ c.GoogleOAuthClientID, _ = GetConfigValue("google-oauth-client-id")
+ c.GoogleOAuthClientSecret, _ = GetConfigValue("google-oauth-client-secret")
+ c.SMTPUsername, _ = GetConfigValue("smtp-username")
+ c.SMTPPassword, _ = GetConfigValue("smtp-password")
+ }
+ r, _ := GetConfigValue("requires-approval")
+ c.RequiresApproval, _ = strconv.ParseBool(r)
+ return c, nil
+}
+
+func GetConfigValue(key string) (string, error) {
row := stmtGetConfig.QueryRow(key)
var val string
err := row.Scan(&val)
if err != nil {
- return Config{}, err
- }
- var c Config
- _, err = toml.Decode(val, &c)
- if err != nil {
- return Config{}, err
+ return "", err
}
- return c, nil
+ return val, nil
}
-func UpdateConfig(key string, value string) error {
+func UpdateConfig(key string, value any) error {
_, err := stmtUpdateConfig.Exec(key, value)
return err
}
-
-// TODO stop this toml nonsense
-func UpdateConfigTOML(c Config) error {
- _, err := stmtUpdateConfig.Exec("config-toml", c.TOMLString())
- if err == nil {
- // update global config
- config = c
- // TODO find a better way to do this
- SetupGoogleOAuth()
- }
- return err
-}
M server/db.go => server/db.go +4 -4
@@ 37,17 37,17 @@ func initdb() {
return
}
}
- prepareStatements(db)
+ PrepareStatements(db)
// squash errors for idempotence
createForum("General", "General discussion")
// create admin / admin
createUser("admin", "webmaster@foo", "admin", RoleAdmin)
// TODO... config
- _, err = GetConfig("config-toml")
+ config, err = GetConfig()
if err != nil {
config := DefaultConfig()
- err = UpdateConfigTOML(config)
+ err = SaveConfig(config)
}
if err != nil {
panic(err)
@@ 80,7 80,7 @@ var stmtGetForumID, stmtUpdateMe, stmtUpdatePassword, stmtSearchPosts,
stmtDeleteUser, stmtUpdateUserRole, stmtUpdateBanStatus, stmtUpdateConfig, stmtGetConfig,
stmtGetThreads, stmtCreateThread, stmtCreateForum *sql.Stmt
-func prepareStatements(db *sql.DB) {
+func PrepareStatements(db *sql.DB) {
stmtGetForums = prepare(db, `
select forums.id, name, description, read_permissions, write_permissions,
coalesce(threadid, 0), coalesce(latest.title, ''), coalesce(latest.id, 0), coalesce(latest.authorid, 0),
M server/post.go => server/post.go +1 -1
@@ 54,7 54,7 @@ func getPostSlug(postid int) (string, error) {
if err != nil {
return "", err
}
- postPage := ((count) / config.PageSize) + 1
+ postPage := ((count) / PageSize) + 1
var url string
// TODO url builder
if postPage != 1 {
M server/server.go => server/server.go +0 -4
@@ 13,7 13,3 @@ var config Config
// TODO parameterize
var programLevel = new(slog.LevelVar)
var log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: programLevel}))
-
-// FishBB is an instance of FishBB
-type FishBB struct {
-}
M server/util.go => server/util.go +2 -2
@@ 58,7 58,7 @@ func GenerateRandomString(n int) (string, error) {
// returns page list
func pageArray(n int) []int {
- c := ((n - 1) / config.PageSize) + 1
+ c := ((n - 1) / PageSize) + 1
p := make([]int, c)
for i := range c {
p[i] = i + 1
@@ 69,7 69,7 @@ func pageArray(n int) []int {
// returns limit, offset
// 1-indexed
func paginate(page int) (int, int) {
- limit := config.PageSize
+ limit := PageSize
offset := (page - 1) * limit
return limit, offset
}
M server/views/control.html => server/views/control.html +13 -4
@@ 3,12 3,21 @@
<div class="body-padded">
<h2>Config</h2>
<form method="POST" action="/update-config">
- Configuration is done via TOML.
- <textarea id="config" name="config" class="post-entry">{{.ConfigTOML}}</textarea>
+ <label for="requires-approval">Registration requires admin approval?
+ </label><br>
+ <select name="requires-approval" id="requires-approval">
+ <option value="1">true</option>
+ <option value="0">false</option>
+ </select><br>
+ <label for="board-description">Board description</label><br>
+ <input name="board-description" id="board-description" value="{{.Config.BoardDescription}}"></input><br>
<input type="hidden" name="CSRF" value="{{.CSRFToken}}"><br>
- <button type="submit">Save</button>
+ {{ if .SingleInstace }}
+ googleconfig goes here
+ {{ end }}
+ <button type="submit" id="submit">Update</button>
</form>
-
+ <hr>
<h2>Forums</h2>
<table>
<tr><th>ID</th><th>Name</th></th><th>Description</th><th>Read Permissions</th><th>Write Permissions</th></tr>
M server/web.go => server/web.go +12 -19
@@ 7,7 7,6 @@ import (
"strconv"
"time"
- "github.com/BurntSushi/toml"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ 27,6 26,7 @@ func serveHTML(w http.ResponseWriter, r *http.Request, name string, info map[str
info["User"] = user
info["Config"] = config
info["Version"] = SoftwareVersion
+ info["SingleInstance"] = SingleInstance
info["CSRFToken"] = GetCSRF(r)
var title = config.BoardName
if info["Subtitle"] != nil {
@@ 407,26 407,19 @@ func controlPanelPage(w http.ResponseWriter, r *http.Request) {
serverError(w, r, err)
return
}
- cfg, err := GetConfig("config-toml")
- if err != nil {
- serverError(w, r, err)
- return
- }
- tmpl["ConfigTOML"] = cfg.TOMLString()
serveHTML(w, r, "control", tmpl)
}
func doUpdateConfig(w http.ResponseWriter, r *http.Request) {
- var c Config
- _, err := toml.Decode(r.FormValue("config"), &c)
- if err != nil {
- serverError(w, r, err)
- return
- }
- err = UpdateConfigTOML(c)
- if err != nil {
- serverError(w, r, err)
- return
+ fields := []string{"board-description"}
+ for _, key := range fields {
+ val := r.PostFormValue(key)
+ err := UpdateConfig(key, val)
+ if err != nil {
+ serverError(w, r, err)
+ return
+ }
+ config, _ = GetConfig()
}
http.Redirect(w, r, "/control", http.StatusSeeOther)
}
@@ 582,9 575,9 @@ func dummy(w http.ResponseWriter, r *http.Request) {
func Serve() {
// order is important here
db = opendb()
- prepareStatements(db)
+ PrepareStatements(db)
var err error
- config, err = GetConfig("config-toml")
+ config, err = GetConfig()
if err != nil {
panic(err)
}
A single.go => single.go +11 -0
@@ 0,0 1,11 @@
+package main
+
+import (
+ fishbb "git.sr.ht/~aw/fishbb/server"
+)
+
+// Run a single instance of fishBB
+func main() {
+ fishbb.SingleInstance = true
+ fishbb.Serve()
+}