Przeglądaj źródła

First day's work on a modern BBS

SSH ONLY! \o/ SFTP! Oooh... JSON for "packets"! Yay.
david 2 tygodni temu
rodzic
commit
cdcb41f777
11 zmienionych plików z 414 dodań i 2 usunięć
  1. 10 0
      .gitignore
  2. 2 2
      README.md
  3. 81 0
      config.go
  4. 13 0
      db.go
  5. 17 0
      go.mod
  6. 98 0
      go.sum
  7. 12 0
      main.go
  8. 53 0
      singletons.go
  9. 52 0
      user.go
  10. 59 0
      user_db.go
  11. 17 0
      utils.go

+ 10 - 0
.gitignore

@@ -1,3 +1,13 @@
+# ---> Lunar Hub
+# Configuration Files
+*.toml
+
+# Databases
+*.db
+
+# "Packets"
+*.json
+
 # ---> Go
 # Compiled Object files, Static and Dynamic libs (Shared Objects)
 *.o

+ 2 - 2
README.md

@@ -1,3 +1,3 @@
-# LunarHub
+# Lunar Hub
 
-Go powered BBS
+Go powered BBS

+ 81 - 0
config.go

@@ -0,0 +1,81 @@
+package main
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/pelletier/go-toml/v2"
+)
+
+type Config struct {
+	// System Identification
+
+	Name  string // The name to refer to this BBS
+	Sysop string // The name to identify the System Operator (SYSOP)
+
+	// Paths
+
+	DataPath    string // Path to users.db, and various *.toml config files
+	MessagePath string // Path to store *.db (for all message bases: local, NETWORKs)
+	FilePath    string // Path to store files (for both NETWORK-received, and local-received)
+	LogPath     string // Path to store *.log files (for lunarhub.log, and more)
+}
+
+func DefaultConfig() *Config {
+	cfg := &Config{
+		Name:  "Lunar Hub",
+		Sysop: "Sysop",
+
+		DataPath:    "data",
+		MessagePath: "msgs",
+		FilePath:    "files",
+		LogPath:     "logs",
+	}
+	cfg.Save()
+	return cfg
+}
+
+func LoadConfig() *Config {
+	in, err := os.ReadFile("lunar_hub.toml")
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return DefaultConfig()
+		} else {
+			panic(fmt.Errorf("load config >> %v", err))
+		}
+	}
+	var data map[string]any = map[string]any{}
+	err = toml.Unmarshal(in, &data)
+	if err != nil {
+		return DefaultConfig()
+	}
+	paths := data["Paths"].(map[string]any)
+	return &Config{
+		Name:        data["Name"].(string),
+		Sysop:       data["Sysop"].(string),
+		DataPath:    paths["Data"].(string),
+		MessagePath: paths["Messages"].(string),
+		FilePath:    paths["Files"].(string),
+		LogPath:     paths["Logs"].(string),
+	}
+}
+
+func (cfg *Config) Save() error {
+	f, err := os.Create("lunar_hub.toml")
+	if err != nil {
+		return err
+	}
+	now := time.Now().Truncate(time.Second).String()
+	f.WriteString("# Lunar Hub - Main Config\n")
+	f.WriteString("# Generated: " + now + "\n\n")
+	f.WriteString("Name = \"" + cfg.Name + "\"\n")
+	f.WriteString("Sysop = \"" + cfg.Sysop + "\"\n\n")
+	f.WriteString("[Paths]\n")
+	f.WriteString("\"Data\" = \"" + cfg.DataPath + "\"\n")
+	f.WriteString("\"Messages\" = \"" + cfg.MessagePath + "\"\n")
+	f.WriteString("\"Files\" = \"" + cfg.FilePath + "\"\n")
+	f.WriteString("\"Logs\" = \"" + cfg.LogPath + "\"\n")
+	return f.Close()
+}

+ 13 - 0
db.go

@@ -0,0 +1,13 @@
+package main
+
+import (
+	"time"
+)
+
+// MODIFIED gorm.Model definition
+type Model struct {
+	ID        uint64 `gorm:"primaryKey"`
+	CreatedAt time.Time
+	UpdatedAt time.Time
+	// DeletedAt gorm.DeletedAt `gorm:"index"` // I want records deleted, not flagged that it's deleted (This does mean data recovery is up-to SYSOP)
+}

+ 17 - 0
go.mod

@@ -0,0 +1,17 @@
+module git.red-green.com/david/lunarhub
+
+go 1.24.5
+
+require (
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/kr/fs v0.1.0 // indirect
+	github.com/mattn/go-sqlite3 v1.14.22 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+	github.com/pkg/sftp v1.13.9 // indirect
+	golang.org/x/crypto v0.40.0 // indirect
+	golang.org/x/sys v0.34.0 // indirect
+	golang.org/x/text v0.27.0 // indirect
+	gorm.io/driver/sqlite v1.6.0 // indirect
+	gorm.io/gorm v1.30.0 // indirect
+)

+ 98 - 0
go.sum

@@ -0,0 +1,98 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
+github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
+golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
+golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
+gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

+ 12 - 0
main.go

@@ -0,0 +1,12 @@
+package main
+
+func main() {
+	Print("Lunar Hub - Startup")
+	Singletons()
+	var err error
+	USERS, err = NewUserManager()
+	if err != nil {
+		LOG.Fatalln("User Manager >>", err)
+	}
+	Print("Lunar Hub - Starting SSH Service")
+}

+ 53 - 0
singletons.go

@@ -0,0 +1,53 @@
+package main
+
+import (
+	"log"
+	"os"
+	"path"
+)
+
+var (
+	CFG   *Config
+	LOG   *log.Logger
+	USERS *UserManager
+)
+
+func Singletons() {
+	// Initialize Config
+	CFG = LoadConfig()
+
+	// Check Paths (Data, Messages, Files, and Logs)
+	if !Exists(CFG.DataPath) {
+		err := os.Mkdir(CFG.DataPath, os.ModeDir)
+		if err != nil {
+			panic("init >> mkdir data ('" + CFG.DataPath + "') >> " + err.Error())
+		}
+	}
+	if !Exists(CFG.MessagePath) {
+		err := os.Mkdir(CFG.MessagePath, os.ModeDir)
+		if err != nil {
+			panic("init >> mkdir msgs ('" + CFG.MessagePath + "') >> " + err.Error())
+		}
+	}
+	if !Exists(CFG.FilePath) {
+		err := os.Mkdir(CFG.FilePath, os.ModeDir)
+		if err != nil {
+			panic("init >> mkdir files ('" + CFG.FilePath + "') >> " + err.Error())
+		}
+	}
+	if !Exists(CFG.LogPath) {
+		err := os.Mkdir(CFG.LogPath, os.ModeDir)
+		if err != nil {
+			panic("init >> mkdir logs ('" + CFG.LogPath + "') >> " + err.Error())
+		}
+	}
+
+	// Establish connection to main Log file ('logs/lunar_hub.log')
+	log_path := path.Join(CFG.LogPath, "lunar_hub.log")
+	f, err := os.OpenFile(log_path, os.O_CREATE|os.O_APPEND|os.O_RDWR|os.O_SYNC, 0666)
+	if err != nil {
+		panic("init >> opening main log ('" + log_path + "') >> " + err.Error())
+	}
+	LOG = log.New(f, "", log.Ldate|log.Ltime|log.Lshortfile)
+	LOG.Println("")
+}

+ 52 - 0
user.go

@@ -0,0 +1,52 @@
+package main
+
+import (
+	"slices"
+	"time"
+)
+
+type User struct {
+	Model
+
+	Handle  string   // Handle/Nickname for User
+	Name    string   // Can be in any format (including left blank!)
+	PrimKey string   // User's Public SSH Key
+	Email   string   // User's Email Address (Used for account recovery, and push notifications)
+	Flags   []string // Flags are words to define certain things ('adult', 'new', 'sysop', and more)
+}
+
+func (u *User) HasFlag(flag string) bool {
+	return slices.Contains(u.Flags, flag)
+}
+
+func (u *User) AddFlag(flag string) {
+	if u.HasFlag(flag) {
+		return
+	}
+	u.Flags = append(u.Flags, flag)
+}
+
+func (u *User) RemoveFlag(flag string) {
+	if !u.HasFlag(flag) {
+		return
+	}
+	var flags []string = make([]string, 0, len(u.Flags)-1)
+	for _, f := range u.Flags {
+		if f != flag {
+			flags = append(flags, f)
+		}
+	}
+	u.Flags = flags
+}
+
+func (u *User) ClearFlags() {
+	u.Flags = []string{}
+}
+
+func (u *User) LastOn() time.Duration {
+	return time.Since(u.UpdatedAt).Round(time.Second)
+}
+
+func (u *User) Joined() time.Duration {
+	return time.Since(u.CreatedAt).Round(time.Second)
+}

+ 59 - 0
user_db.go

@@ -0,0 +1,59 @@
+package main
+
+import (
+	"path"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+)
+
+// Manages the User Database
+//
+// Default location is 'data/users.db'
+type UserManager struct {
+	db *gorm.DB
+}
+
+func NewUserManager() (*UserManager, error) {
+	var (
+		um  *UserManager = &UserManager{}
+		err error
+	)
+	um.db, err = gorm.Open(sqlite.Open(path.Join(CFG.DataPath, "users.db")), &gorm.Config{})
+	if err != nil {
+		return nil, err
+	}
+	err = um.db.AutoMigrate(&User{})
+	if err != nil {
+		return nil, err
+	}
+	return um, err
+}
+
+func (um *UserManager) Close() error {
+	sqldb, err := um.db.DB()
+	if err != nil {
+		return err
+	}
+	return sqldb.Close()
+}
+
+func (um *UserManager) FindByHandle(handle string) (*User, error) {
+	var u *User
+	tx := um.db.First(u, "handle = ?", handle)
+	return u, tx.Error
+}
+
+func (um *UserManager) SaveUser(u *User) error {
+	tx := um.db.Save(u)
+	return tx.Error
+}
+
+func (um *UserManager) DeleteUser(u *User) error {
+	tx := um.db.Delete(u)
+	if tx.Error != nil {
+		return tx.Error
+	}
+	u = nil
+	return nil
+}

+ 17 - 0
utils.go

@@ -0,0 +1,17 @@
+package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func Exists(path string) bool {
+	var err error
+	_, err = os.Stat(path)
+	return err == nil
+}
+
+func Print(a ...any) {
+	fmt.Println(a...)
+	LOG.Println(a...)
+}