~aw/fishbb

996d06d61aea248ae45f5087299827c68f576cbf — alex wennerberg 15 days ago 9f954c2
wip config changes etc
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()
}