Skip to content

Commit

Permalink
Add invites functionality (#92)
Browse files Browse the repository at this point in the history
* implement first pass of GetAllInvites routine
* remember to use references correctly in golang maps kids :):)
* use fmt.Errorf instead of errors.New(fmt.Sprintf) to write err descriptions
* invites: we crudding now - hook up database to server and html forms

we can now create and delete batches of invites, and render them in the
html view at /invites

i also decided to start contain server routes within constants written
in server/server.go, to make it easier to change them or whatever. just
feels less slightly messy than strings errywhere X)

* invites: remove allowlist and instead use invite paradigm for registration

this removes a lot of extra cruft from maaany places! one consequence of
staying consistent however is the creation of
"registration-instructions.md" and needing to update the local config in the
following way:

-verification_instructions = "content/verification-instructions.md"
+registration_instructions = "content/registration-instructions.md"

i.e. the 'verification_instructions' key is no longer, and is instead
reaplced with 'registration_instructions'. we could've kept the old
name, but in this instance considering the relatively few deployments
(known to me, at least) of cerca today. better to have a good name than
a legacy name in this instance

* invites: add descriptive text and log invite acts to modlog
* invites: implement reusable invites
* invites: show tally of claimed invites by batchid

this is visible on /admin. for invites that have been claimed and whose
batch has either been completely exhausted or deleted by an admin, their
corresponding labels will be rendered as "unlabeled" which is why we
also make it possible to see the batch id.

we use "invite/register info" because for the first deployment of cerca,
urls were used as a way of noting incoming registrations. probably a
good thing to fix up the naming of in a future migration, but not
important enough to necessitate its own migration

closes #39 #75 #47
  • Loading branch information
cblgh authored Dec 16, 2024
1 parent 107a554 commit d442969
Show file tree
Hide file tree
Showing 22 changed files with 554 additions and 215 deletions.
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ It was written for the purpose of powering the nascent [Merveilles community for
* **Customizable**: Many of Cerca's facets are customizable and the structure is intentionally simple to enable DIY modification
* **Private**: Threads are public viewable by default but new threads may be set as private, restricting views to logged-in users only
* **Easy admin**: A simple admin panel lets you add users, reset passwords, and remove old accounts. Impactful actions require two admins to perform, or a week of time to pass without a veto from any admin
* **Invites**: Fully-featured system for creating invites both one-time and reusable invites. Admins can monitor invite redemption by batch as well as issue and delete batches of invites. Accessible using the same simple type of web interface that services the rest of the forum's administration tasks.
* **Transparency**: Actions taken by admins are viewable by any logged-in user in the form of a moderation log
* **Low maintenance**: Cerca is architected to minimize maintenance and hosting costs by carefully choosing which features it supports, how they work, and which features are intentionally omitted
* **RSS**: Receive updates when threads are created or new posts are made by subscribing to the forum RSS feed
Expand All @@ -32,8 +33,8 @@ cerca --help
USAGE:
run the forum
cerca -allowlist allow.txt -authkey "CHANGEME"
cerca -dev
cerca -authkey "CHANGEME"
cerca -dev
COMMANDS:
adduser create a new user
Expand All @@ -42,8 +43,6 @@ COMMANDS:
resetpw reset a user's password
OPTIONS:
-allowlist string
domains which can be used to read verification codes from during registration
-authkey string
session cookies authentication key
-config string
Expand All @@ -64,7 +63,7 @@ cerca adduser --username "<username>"
Cerca supports community customization.

* Write a custom [about text](/defaults/sample-about.md) describing the community inhabiting the forum
* Define your own [registration rules](/defaults/sample-rules.md), [how to verify one's account](/defaults/sample-verification-instructions.md), and link to an existing code of conduct
* Define your own [registration rules](/defaults/sample-rules.md), [instructions on getting an invite code to register](/defaults/sample-registration-instructions.md), and link to an existing code of conduct
* Set your own [custom logo](/defaults/sample-logo.html) (whether svg, png or emoji)
* Create your own theme by writing plain, frameworkless [css](/html/assets/theme.css)

Expand Down Expand Up @@ -93,7 +92,7 @@ forum_url = "" # should be forum index route https://example.com. used to genera
logo = "content/logo.html" # can contain emoji, <img>, <svg> etc. see defaults/sample-logo.html in repo for instructions
about = "content/about.md"
rules = "content/rules.md"
verification_instructions = "content/verification-instructions.md"
registration_instructions = "content/registration-instructions.md"
```

Content documents that are not found will be prepopulated using Cerca's [sample content
Expand Down
39 changes: 2 additions & 37 deletions cmd/cerca/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"flag"
"fmt"
"net/url"
"os"
"strings"

Expand Down Expand Up @@ -45,22 +44,6 @@ func usage(help string, fset *flag.FlagSet) {
flag.PrintDefaults()
}

func readAllowlist(location string) []string {
ed := util.Describe("read allowlist")
data, err := os.ReadFile(location)
ed.Check(err, "read file")
list := strings.Split(strings.TrimSpace(string(data)), "\n")
var processed []string
for _, fullpath := range list {
u, err := url.Parse(fullpath)
if err != nil {
continue
}
processed = append(processed, u.Host)
}
return processed
}

func inform(msg string, args ...interface{}) {
if len(args) > 0 {
fmt.Printf("%s\n", fmt.Sprintf(msg, args...))
Expand All @@ -79,21 +62,18 @@ func complain(msg string, args ...interface{}) {
}

func run() {
// TODO (2022-01-10): somehow continually update veri sites by scraping merveilles webring sites || webring domain
var allowlistLocation string
var sessionKey string
var configPath string
var dataDir string
var dev bool

flag.BoolVar(&dev, "dev", false, "trigger development mode")
flag.StringVar(&allowlistLocation, "allowlist", "", "domains which can be used to read verification codes from during registration")
flag.StringVar(&sessionKey, "authkey", "", "session cookies authentication key")
flag.StringVar(&configPath, "config", "cerca.toml", "config and settings file containing cerca's customizations")
flag.StringVar(&dataDir, "data", "./data", "directory where cerca will dump its database")

help := createHelpString("run", []string{
"cerca -allowlist allow.txt -authkey \"CHANGEME\"",
"cerca -authkey \"CHANGEME\"",
"cerca -dev",
})
flag.Usage = func() { usage(help, nil) }
Expand All @@ -110,28 +90,13 @@ func run() {
}
sessionKey = "0"
}
if len(allowlistLocation) == 0 {
if !dev {
complain("please pass a file containing the verification code domain allowlist")
}
allowlistLocation = "temp-allowlist.txt"
created, err := util.CreateIfNotExist(allowlistLocation, "")
if err != nil {
complain(fmt.Sprintf("couldn't create %s: %s", allowlistLocation, err))
}
if created {
fmt.Println(fmt.Sprintf("Created %s", allowlistLocation))
}
}

err := os.MkdirAll(dataDir, 0750)
if err != nil {
complain(fmt.Sprintf("couldn't create dir '%s'", dataDir))
}
allowlist := readAllowlist(allowlistLocation)
allowlist = append(allowlist, "merveilles.town")
config := util.ReadConfig(configPath)
server.Serve(allowlist, sessionKey, dev, dataDir, config)
server.Serve(sessionKey, dev, dataDir, config)
}

func main() {
Expand Down
2 changes: 2 additions & 0 deletions constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const (
MODLOG_ADMIN_PROPOSE_DEMOTE_ADMIN
MODLOG_ADMIN_PROPOSE_MAKE_ADMIN
MODLOG_ADMIN_PROPOSE_REMOVE_USER
MODLOG_CREATE_INVITE_BATCH
MODLOG_DELETE_INVITE_BATCH
/* NOTE: when adding new values, only add them after already existing values! otherwise the existing variables will
* receive new values which affects the stored values in table moderation_log */
)
Expand Down
28 changes: 2 additions & 26 deletions crypto/crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ package crypto

import (
"cerca/util"
crand "crypto/rand"
"encoding/binary"
"crypto/rand"
"github.com/matthewhartstonge/argon2"
"math/big"
rand "math/rand"
"strings"
)

Expand Down Expand Up @@ -41,32 +39,10 @@ func GeneratePassword() string {

for i := 0; i < pwlength; i++ {
max := big.NewInt(maxChar)
bigN, err := crand.Int(crand.Reader, max)
bigN, err := rand.Int(rand.Reader, max)
util.Check(err, "randomly generate int")
n := bigN.Int64()
password.WriteString(string(characterSet[n]))
}
return password.String()
}

func GenerateVerificationCode() int {
var src cryptoSource
rnd := rand.New(src)
return rnd.Intn(999999)
}

type cryptoSource struct{}

func (s cryptoSource) Seed(seed int64) {}

func (s cryptoSource) Int63() int64 {
return int64(s.Uint64() & ^uint64(1<<63))
}

func (s cryptoSource) Uint64() (v uint64) {
err := binary.Read(crand.Reader, binary.BigEndian, &v)
if err != nil {
util.Check(err, "generate random verification code")
}
return v
}
34 changes: 21 additions & 13 deletions database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"errors"
"fmt"
"log"
"net/url"
"os"
"time"

Expand Down Expand Up @@ -124,7 +123,20 @@ func createTables(db *sql.DB) {
FOREIGN KEY (recipientid) REFERENCES users(id)
);
`,
`
`
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batchid TEXT NOT NULL, -- uuid v4
invite TEXT NOT NULL,
label TEXT,
adminid INTEGER NOT NULL,
time DATE NOT NULL,
reusable BOOL NOT NULL,
FOREIGN KEY(adminid) REFERENCES users(id)
);
`,
`
CREATE TABLE IF NOT EXISTS registrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userid INTEGER,
Expand Down Expand Up @@ -514,15 +526,11 @@ func (d DB) GetSystemUserid() int {
return systemUserid
}

func (d DB) AddRegistration(userid int, verificationLink string) error {
func (d DB) AddRegistration(userid int, registrationOrigin string) error {
ed := util.Describe("add registration")
stmt := `INSERT INTO registrations (userid, host, link, time) VALUES (?, ?, ?, ?)`
stmt := `INSERT INTO registrations (userid, link, time) VALUES (?, ?, ?)`
t := time.Now()
u, err := url.Parse(verificationLink)
if err = ed.Eout(err, "parse url"); err != nil {
return err
}
_, err = d.Exec(stmt, userid, u.Host, verificationLink, t)
_, err := d.Exec(stmt, userid, registrationOrigin, t)
if err = ed.Eout(err, "add registration"); err != nil {
return err
}
Expand All @@ -533,10 +541,10 @@ func (d DB) AddRegistration(userid int, verificationLink string) error {

func (d DB) GetUsers(includeAdmin bool) []User {
ed := util.Describe("get users")
query := `SELECT u.name, u.id
FROM users u
query := `SELECT u.name, u.id, r.link
FROM users u INNER JOIN registrations r on u.id = r.userid
%s
ORDER BY u.name
ORDER BY u.id
`

if includeAdmin {
Expand All @@ -556,7 +564,7 @@ func (d DB) GetUsers(includeAdmin bool) []User {
var user User
var users []User
for rows.Next() {
if err := rows.Scan(&user.Name, &user.ID); err != nil {
if err := rows.Scan(&user.Name, &user.ID, &user.RegistrationOrigin); err != nil {
ed.Check(err, "scanning loop")
}
users = append(users, user)
Expand Down
Loading

0 comments on commit d442969

Please sign in to comment.