You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

672 lines
18 KiB

// https://github.com/robinsloan/spring-83-spec/blob/main/draft-20220616.md
// TODO:
// * check that the <time> tag in the body
// * implement peer sharing and receiving
// * display each board in a region with an aspect ratio of either 1:sqrt(2) or sqrt(2):1
// * add <link> elements:
// * However, it is presumed that a home page or profile page might contain a <link> element analogous to the kind used to specify RSS feeds. A client scanning a web page for an associated board should look for <link> elements with the type attribute set to text/board+html.
// <link rel="alternate" type="text/board+html" href="https://bogbody.biz/ca93846ae61903a862d44727c16fed4b80c0522cab5e5b8b54763068b83e0623" />
// * scan for <link rel="next"...> links as specified in the spec
// * add unique request ID
package server
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"database/sql"
"embed"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"math"
"net/http"
"os"
"regexp"
"runtime"
"runtime/debug"
"strings"
"syscall"
"text/template"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
)
const MAX_KEY_64 = (1 << 64) - 1
const MAX_BODY_SIZE = 2217
var (
// For the convenience of server implementers, the <time> element must fit
// the following format exactly; "valid HTML" is not sufficient:
// <time datetime="YYYY-MM-DDTHH:MM:SSZ">
TIME_RE = regexp.MustCompile("<time datetime=\".{19}Z\">")
//go:embed templates/*
templateFS embed.FS
// information about the process, for logging
process = os.Args[0]
pid = syscall.Getpid()
goversion = runtime.Version()
// set by initRuntimeValues
vcsRevision string
vcsTime string
)
func must(err error, log zerolog.Logger) {
if err != nil {
log.Fatal().Err(err).Msg("")
}
}
func InitDB(log zerolog.Logger) *sql.DB {
dbName := "./spring83.db"
// if the db doesn't exist, create it
if _, err := os.Stat(dbName); errors.Is(err, os.ErrNotExist) {
log.Info().Msg("initializing new database")
db, err := sql.Open("sqlite3", dbName)
must(err, log)
initSQL := `
CREATE TABLE boards (
key text NOT NULL PRIMARY KEY,
board text,
creation_datetime text,
expiry_datetime text
);
`
_, err = db.Exec(initSQL)
if err != nil {
log.Fatal().Msgf("%q: %s\n", err, initSQL)
}
return db
}
db, err := sql.Open("sqlite3", dbName)
must(err, log)
return db
}
// initCleaner starts a goroutine that will delete any expired entries every 5 minutes
func InitCleaner(db *sql.DB, log zerolog.Logger) {
removeExpiredBoards(db, log)
ticker := time.NewTicker(5 * time.Minute)
go func() {
for {
<-ticker.C
removeExpiredBoards(db, log)
}
}()
}
type responseRecorder struct {
http.ResponseWriter
status int
}
func (rec *responseRecorder) WriteHeader(code int) {
rec.status = code
rec.ResponseWriter.WriteHeader(code)
}
func RequestLogger(log zerolog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &responseRecorder{w, http.StatusOK}
t := time.Now()
next.ServeHTTP(rec, r)
log.Info().
Dur("duration", time.Since(t)).
Int("status", rec.status).
Str("method", r.Method).
Str("path", r.URL.Path).
Str("process", process).
Int("pid", pid).
Str("goversion", goversion).
Str("peer-ip", r.RemoteAddr).
Str("difficulty", w.Header().Get("Spring-Difficulty")).
Str("vcsRevision", vcsRevision).
Str("vcsTime", vcsTime).
Msg("Request served")
})
}
func InitRuntimeValues(log zerolog.Logger) {
if bi, ok := debug.ReadBuildInfo(); !ok {
log.Fatal().Msg("Unable to get build info")
} else {
for i := range bi.Settings {
switch bi.Settings[i].Key {
case "vcs.revision":
vcsRevision = bi.Settings[i].Value
case "vcs.time":
vcsTime = bi.Settings[i].Value
}
}
}
}
func removeExpiredBoards(db *sql.DB, log zerolog.Logger) {
t := time.Now()
dt := t.Format(time.RFC3339)
// This should work because ISO datetimes are lexicographically ordered
res, err := db.Exec(`
DELETE FROM boards
WHERE expiry_datetime < ?
`, dt)
if err != nil {
log.Error().Err(err).Msg("Error deleting board")
}
rows, err := res.RowsAffected()
if err != nil {
log.Error().Err(err).Msg("Error getting deleted board rows affected")
}
log.Info().Int64("deleted", rows).Dur("duration", time.Since(t)).Msg("entries cleaned")
}
func readTemplate(name string, fsys fs.FS) (string, error) {
h, err := fs.ReadFile(fsys, name)
if err != nil {
return "", err
}
return string(h), nil
}
func mustTemplate(name string, fsys fs.FS) *template.Template {
f, err := readTemplate(name, fsys)
if err != nil {
panic(err)
}
t, err := template.New("index").Parse(f)
if err != nil {
panic(err)
}
return t
}
type Board struct {
Key string
Board string
Creation time.Time
Expiry time.Time
}
type Spring83Server struct {
db *sql.DB
log zerolog.Logger
homeTemplate *template.Template
}
func NewSpring83Server(db *sql.DB, log zerolog.Logger) *Spring83Server {
return &Spring83Server{
db: db,
log: log,
homeTemplate: mustTemplate("templates/index.html", templateFS),
}
}
func (s *Spring83Server) getBoard(key string) (*Board, error) {
query := `
SELECT key, board, creation_datetime, expiry_datetime
FROM boards
WHERE key=?
`
row := s.db.QueryRow(query, key)
var dbkey, board, creation, expiry string
err := row.Scan(&dbkey, &board, &creation, &expiry)
if err != nil {
if err != sql.ErrNoRows {
return nil, err
}
return nil, nil
}
creationTime, err := time.Parse(time.RFC3339, creation)
if err != nil {
return nil, err
}
expTime, err := time.Parse(time.RFC3339, expiry)
if err != nil {
return nil, err
}
return &Board{
Key: key,
Board: board,
Creation: creationTime,
Expiry: expTime,
}, nil
}
func (s *Spring83Server) boardCount() (int, error) {
query := `
SELECT count(*)
FROM boards
`
row := s.db.QueryRow(query)
var count int
err := row.Scan(&count)
if err != nil {
if err != sql.ErrNoRows {
return 0, err
}
panic(err)
}
return count, nil
}
func (s *Spring83Server) getDifficulty() (float64, uint64, error) {
count, err := s.boardCount()
if err != nil {
return 0, 0, err
}
// The calculation of the difficulty factor is not part of this specification, but here's an example formula that works well:
//
// difficulty_factor = ( num_boards_stored / max_boards )**4
//
// a threshold defined by the server's difficulty factor:
//
// MAX_KEY_64 = (2**64 - 1)
// key_64_threshold = round(MAX_KEY_64 * ( 1.0 - difficulty_factor))
difficultyFactor := math.Pow(float64(count)/10_000_000, 4)
keyThreshold := uint64(math.Round(MAX_KEY_64 * (1.0 - difficultyFactor)))
return difficultyFactor, keyThreshold, nil
}
func (s *Spring83Server) publishBoard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Spring-Version", "83")
body, err := ioutil.ReadAll(r.Body)
if err != nil {
panic(err)
}
if len(body) > MAX_BODY_SIZE {
http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge)
return
}
key, err := hex.DecodeString(r.URL.Path[1:])
if err != nil || len(key) != 32 {
http.Error(w, "Invalid key", http.StatusBadRequest)
return
}
keyStr := fmt.Sprintf("%x", key)
// curBoard is nil if there is no existing board for this key, and a Board object otherwise
curBoard, err := s.getBoard(keyStr)
if err != nil {
s.log.Err(err).Msg("Unable to get board")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// if the server doesn't have any board stored for <key>, then it must
// apply another check. The key, interpreted as a 256-bit number, must be
// less than a threshold defined by the server's difficulty factor:
if curBoard == nil {
difficultyFactor, keyThreshold, err := s.getDifficulty()
if err != nil {
s.log.Err(err).Msg("Unable to get difficulty")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.log.Info().Float64("diff", difficultyFactor).Msg("difficulty")
w.Header().Add("Spring-Difficulty", fmt.Sprintf("%f", difficultyFactor))
// If the server doesn't yet have any board stored for the key, then it
// must apply an additional check. The key's first 16 hex characters,
// interpreted as a 64-bit number, must be less than a threshold
// defined by the server's difficulty factor:
//
// MAX_KEY_64 = (2**64 - 1)
// key_64_threshold = round(MAX_KEY_64 * ( 1.0 - difficulty_factor))
//
// If the key fails this check, the server must reject the PUT request, returning 403 Forbidden.
if binary.BigEndian.Uint64(key[:8]) < keyThreshold {
if err != nil || len(key) != 32 {
http.Error(w, "Key greater than threshold", http.StatusForbidden)
return
}
}
}
// Verify that the provided signature matches the body content
var signature []byte
if authorizationHeaders, ok := r.Header["Authorization"]; !ok {
http.Error(w, "Missing Authorization header", http.StatusBadRequest)
return
} else {
parts := strings.Split(authorizationHeaders[0], " ")
if parts[0] != "Spring-83" || len(parts) < 2 {
http.Error(w, "Invalid Authorization Type", http.StatusBadRequest)
return
}
sig := strings.Split(parts[1], "=")
if len(sig) < 1 {
http.Error(w, "Invalid Signature", http.StatusBadRequest)
return
}
sigString := sig[1]
if len(sigString) != 128 {
http.Error(w, fmt.Sprintf("Expecting 64-bit signature %s %d", sigString, len(sigString)), http.StatusBadRequest)
return
}
signature, err = hex.DecodeString(sigString)
if err != nil {
http.Error(w, "Unable to decode signature", http.StatusBadRequest)
return
}
}
// Spring '83 specifies a test keypair
// Servers must not accept PUTs for this key, returning 401 Unauthorized.
// The server may also use a denylist to block certain keys, rejecting all PUTs for those keys.
denylist := []string{"fad415fbaa0339c4fd372d8287e50f67905321ccfd9c43fa4c20ac40afed1983"}
for _, key := range denylist {
if bytes.Equal(signature, []byte(key)) {
http.Error(w, "Denied", http.StatusUnauthorized)
}
}
// A conforming key's final seven hex characters must be "83e" followed by
// four characters that, interpreted as MMYY, express a valid month and
// year in the range 01/00 .. 12/99. Formally, the key must match this
// regex:
// /83e(0[1-9]|1[0-2])(\d\d)$/
//
// If the key does not match that regex, the server must reject the
// request, returning 403 Forbidden.
//
// The key is only valid in the two years preceding its encoded expiration
// date, and expires at the end of the last day of the month specified. For
// example, the key
last4 := string(keyStr[60:64])
last4Time, err := time.Parse("0106", last4)
if err != nil {
s.log.Error().Str("last4", last4).Msg("Failed parsing last4")
http.Error(w, "Key must end with 83eMMYY", http.StatusBadRequest)
return
}
// This isn't quite the correct key expiry date; techncially the key
// expires on the last day of the month of its issuance; here we're just
// giving it an extra month. TODO be more accurate
twoYearsInHours := (365 * 2 * 24.0) + 31*24.0
timeDiff := time.Until(last4Time).Hours()
if keyStr[57:60] != "83e" {
s.log.Error().Str("keyStr", keyStr[57:60]).Msg("Expected 83e")
http.Error(w, "Key must end with 83eMMYY", http.StatusBadRequest)
return
}
if timeDiff > twoYearsInHours {
s.log.Error().Float64("timeDiff", timeDiff).Msg("Key too far in future")
http.Error(w, "Key is not yet valid", http.StatusBadRequest)
return
}
if timeDiff < 0 {
s.log.Error().Float64("timeDiff", timeDiff).Msg("Key expired")
http.Error(w, "Key is expired", http.StatusBadRequest)
return
}
// at this point, we should have met all the preconditions prior to the
// cryptographic check. By the spec, we should perform all
// non-cryptographic checks first.
if !ed25519.Verify(key, body, signature) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// The server must reject the PUT request, returning 400 Bad Request, if
//
// - the request is transmitted without a <time> element; or
// - its <time> element's datetime attribute is not a UTC timestamp in ISO
// 8601 format; or
// - its <time> element's datetime attribute is set to a timestamp in the
// future.
match := TIME_RE.Find(body)
if match == nil {
s.log.Error().Bytes("body", body).Msg("No time element in body")
http.Error(w, "Missing time element in body", http.StatusBadRequest)
return
}
if len(match) != 38 {
s.log.Error().Bytes("match", match).Int("len", len(match)).Msg("invalid match length")
http.Error(w, "Invalid time element in body", http.StatusBadRequest)
return
}
bodyTime, err := time.Parse(time.RFC3339, string(match[16:36]))
if err != nil {
s.log.Error().Bytes("matchtime", match[16:36]).Msg("Unable to parse")
http.Error(w, "Invalid time element in body", http.StatusBadRequest)
return
}
if time.Now().Before(bodyTime) {
s.log.Error().Time("bodyTime", bodyTime).Msg("Future time")
http.Error(w, "Future times are not allowed", http.StatusBadRequest)
return
}
if curBoard != nil && bodyTime.Before(curBoard.Creation) {
s.log.Error().Time("bodyTime", bodyTime).Msg("Old content")
http.Error(w, "Old content", http.StatusConflict)
return
}
expiry := time.Now().AddDate(0, 0, 7).Format(time.RFC3339)
bodyTimeISO := bodyTime.Format(time.RFC3339)
_, err = s.db.Exec(`
INSERT INTO boards (key, board, creation_datetime, expiry_datetime)
values(?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
board=?,
creation_datetime=?,
expiry_datetime=?
WHERE key=?
`, keyStr, body, bodyTimeISO, expiry, body, bodyTimeISO, expiry, keyStr)
if err != nil {
s.log.Error().Err(err).Msg("Error inserting board")
http.Error(w, "Server error", http.StatusInternalServerError)
}
}
func (s *Spring83Server) loadBoards() ([]Board, error) {
query := `
SELECT key, board, creation_datetime, expiry_datetime
FROM boards
`
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
boards := []Board{}
for rows.Next() {
var key, board, creation, expiry string
err = rows.Scan(&key, &board, &creation, &expiry)
if err != nil {
return nil, err
}
creationTime, err := time.Parse(time.RFC3339, creation)
if err != nil {
return nil, err
}
expTime, err := time.Parse(time.RFC3339, expiry)
if err != nil {
return nil, err
}
boards = append(boards, Board{
Key: key,
Board: board,
Creation: creationTime,
Expiry: expTime,
})
}
return boards, nil
}
func randstr() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
panic("failed to read random bytes to create random string")
}
// format it in hexadecimal, and start it with an n because html can have
// problems with strings starting with 0 and we're using it as a nonce
return fmt.Sprintf("n%x", buf)
}
// for now, on loads to /, I'm just going to show all boards no matter what
func (s *Spring83Server) showAllBoards(w http.ResponseWriter, r *http.Request) {
boards, err := s.loadBoards()
if err != nil {
s.log.Err(err).Msg("Unable to load boards")
http.Error(w, "Unable to load boards", http.StatusInternalServerError)
return
}
difficultyFactor, _, err := s.getDifficulty()
if err != nil {
s.log.Err(err).Msg("Unable to get difficulty")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Add("Spring-Difficulty", fmt.Sprintf("%f", difficultyFactor))
nonce := randstr()
policy := []string{
"default-src: 'none'",
"style-src: 'self' 'unsafe-inline'",
"font-src 'self'",
fmt.Sprintf("script-src 'nonce-%s'", nonce),
"form-action *",
"connect-src *",
"img-src self",
}
w.Header().Add("Content-Security-Policy", strings.Join(policy, "; "))
boardBytes, err := json.Marshal(boards)
if err != nil {
s.log.Err(err).Msg("Unable to marshal boards")
http.Error(w, "Unable to marshal boards", http.StatusInternalServerError)
return
}
data := struct {
Boards string
Nonce string
}{
Boards: string(boardBytes),
Nonce: nonce,
}
s.homeTemplate.Execute(w, data)
}
func (s *Spring83Server) showBoard(w http.ResponseWriter, r *http.Request) {
board, err := s.getBoard(r.URL.Path[1:])
if err != nil {
s.log.Err(err).Str("path: ", r.URL.Path).Msg("Unable to get board")
http.Error(w, "Unable to load boards", http.StatusInternalServerError)
return
}
if board == nil {
http.Error(
w,
fmt.Sprintf("Could not find board %s", r.URL.Path[1:]),
http.StatusNotFound)
return
}
difficultyFactor, _, err := s.getDifficulty()
if err != nil {
s.log.Err(err).Msg("Unable to get difficulty")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Add("Spring-Difficulty", fmt.Sprintf("%f", difficultyFactor))
// XXX: we want to block all javascript from executing, except for our own
// script, with a CSP but I'm not sure exactly how to do that. This does
// seem to block a simple onclick handler I added to the code, which is
// nice
nonce := randstr()
w.Header().Add("Content-Security-Policy", fmt.Sprintf("script-src 'nonce-%s'; img-src 'self'", nonce))
boardBytes, err := json.Marshal([]*Board{board})
if err != nil {
s.log.Err(err).Msg("Unable to marshal board")
http.Error(w, "Unable to marshal boards", http.StatusInternalServerError)
return
}
data := struct {
Boards string
Nonce string
}{
Boards: string(boardBytes),
Nonce: nonce,
}
// for now just be lazy and don't give this page its own template, re-use
// the page designed to show all boards
s.homeTemplate.Execute(w, data)
}
func (s *Spring83Server) Options(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Access-Control-Allow-Methods", "GET, PUT, OPTIONS")
w.Header().Add("Access-Control-Allow-Origin", "*")
w.Header().Add("Access-Control-Allow-Headers", "Content-Type, If-Modified-Since, Spring-Signature, Spring-Version")
w.Header().Add("Access-Control-Expose-Headers", "Content-Type, Last-Modified, Spring-Difficulty, Spring-Signature, Spring-Version")
w.WriteHeader(http.StatusNoContent)
}
func (s *Spring83Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "OPTIONS":
s.Options(w, r)
case "PUT":
s.publishBoard(w, r)
case "GET":
if len(r.URL.Path) == 1 {
s.showAllBoards(w, r)
} else {
s.showBoard(w, r)
}
default:
http.Error(w, "Invalid method", http.StatusBadRequest)
}
}