barf: some local updates I forgot to push

This commit is contained in:
Luke Granger-Brown 2024-03-27 00:36:06 +00:00
parent 401b903cdb
commit 8ee5597278
9 changed files with 576 additions and 56 deletions

View file

@ -8,10 +8,16 @@
systemd.targets.barf = {
wantedBy = [ "multi-user.target" ];
};
security.wrappers.barf-cli = {
source = "${depot.web.barf.frontend}/bin/barfcli";
setuid = true;
owner = "root";
group = "root";
};
systemd.services.barf-fe = {
wantedBy = [ "barf.target" ];
serviceConfig = {
ExecStart = "${depot.web.barf.frontend}/bin/frontend -serve=:12001 -sam_backend=http://127.0.0.1:11316 -db_path=/var/lib/barf-fe/db.db";
ExecStart = "${depot.web.barf.frontend}/bin/barffe -serve=:12001 -sam_backend=http://127.0.0.1:11316 -db_path=/var/lib/barf-fe/db.db";
StateDirectory = "barf-fe";
User = "barf-fe";
PrivateTmp = true;

View file

@ -0,0 +1,133 @@
package main
import (
"database/sql"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"hg.lukegb.com/lukegb/depot/web/barf/frontend/barfdb"
_ "github.com/mattn/go-sqlite3"
)
var (
db *barfdb.DB
flagDBPath string
)
var rootCmd = &cobra.Command{
Use: "barfcli",
Short: "BARF is the Birthday Activities Registration Form",
Long: `Manage your Birthday Activities`,
}
var initializeCmd = &cobra.Command{
Use: "initialize",
Short: "Initialize a new database",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
return db.Initialize(cmd.Context())
},
}
var addCmd = &cobra.Command{
Use: "add keyprefix name",
Short: "Add a new key",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
key, err := db.GetOrCreateKey(ctx, args[0], &barfdb.Dataset{Name: args[1]})
if err != nil {
return err
}
fmt.Println(key)
return nil
},
}
var lsCmd = &cobra.Command{
Use: "ls",
Short: "List keys and respondents",
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
entries, err := db.List(ctx)
if err != nil {
return err
}
maxLengths := []int{
len("basekey"),
len("key"),
len("name"),
}
for _, entry := range entries {
for n, l := range []int{
len(entry.BaseKey()),
len(entry.FullKey),
len(entry.Dataset.Name),
} {
if maxLengths[n] < l {
maxLengths[n] = l
}
}
}
fmtFmt := fmt.Sprintf("%%-%ds\t%%-%ds\t%%-%ds\t%%v\n", maxLengths[0], maxLengths[1], maxLengths[2])
fmt.Printf(fmtFmt, "basekey", "key", "name", "completed")
fmt.Printf(fmtFmt, strings.Repeat("-", maxLengths[0]), strings.Repeat("-", maxLengths[1]), strings.Repeat("-", maxLengths[2]), "---------")
for _, entry := range entries {
fmt.Printf(fmtFmt, entry.BaseKey(), entry.FullKey, entry.Dataset.Name, entry.Completed())
}
return nil
},
}
var getCmd = &cobra.Command{
Use: "get short",
Short: "Get responses for a user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
resp, err := db.GetResponseForShortKey(ctx, args[0])
if err != nil {
return err
}
hs, err := resp.HumanString()
if err != nil {
return err
}
fmt.Println(hs)
return nil
},
}
func openDB() {
sdb, err := sql.Open("sqlite3", flagDBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Opening database %v: %v\n", flagDBPath, err)
os.Exit(1)
return
}
db = barfdb.New(sdb)
}
func closeDB() {
db.Close()
}
func init() {
cobra.OnInitialize(openDB)
cobra.OnFinalize(closeDB)
rootCmd.PersistentFlags().StringVar(&flagDBPath, "db", "/var/lib/barf-fe/db.db", "BARF database.")
rootCmd.AddCommand(initializeCmd, addCmd, lsCmd, getCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View file

@ -0,0 +1,196 @@
package barfdb
import (
"context"
"crypto/rand"
"database/sql"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"strings"
)
type Dataset struct {
CurrentPhase int `json:"currentPhase"`
CurrentPhaseDialog int `json:"currentPhaseDialog"`
AudioEnabled bool `json:"audioEnabled"`
Name string `json:"name"`
DiscordUsername string `json:"discordUsername"`
ReceiveEmail bool `json:"receiveEmail"`
Email string `json:"email"`
DateSat31August string `json:"dateSat31August"`
DateSat7September string `json:"dateSat7September"`
ActivityEscapeRoom string `json:"activityEscapeRoom"`
ActivityPub string `json:"activityPub"`
ActivityMeanGirlsTheMusical string `json:"activityMeanGirlsTheMusical"`
ActivityKaraoke string `json:"activityKaraoke"`
AccommodationRequired bool `json:"accommodationRequired"`
TravelCosts bool `json:"travelCosts"`
Misc string `json:"misc"`
}
func (ds *Dataset) Scan(value any) error {
return json.Unmarshal([]byte(value.(string)), ds)
}
func (ds *Dataset) Value() (driver.Value, error) {
b, err := json.Marshal(ds)
return string(b), err
}
func (ds *Dataset) HumanString() (string, error) {
bs, err := json.MarshalIndent(ds, "", " ")
if err != nil {
return "", err
}
return string(bs), nil
}
type DB struct {
db *sql.DB
}
func New(sdb *sql.DB) *DB {
return &DB{sdb}
}
func (d *DB) Close() error {
return d.db.Close()
}
var ErrNoSuchKey = errors.New("barfdb: no such key")
const (
keyBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
keyLength = 12
)
func (d *DB) Initialize(ctx context.Context) error {
_, err := d.db.ExecContext(ctx, "CREATE TABLE responses (key STRING PRIMARY KEY NOT NULL, responses JSONB NOT NULL)")
return err
}
type Entry struct {
FullKey string
Dataset Dataset
}
func (e Entry) BaseKey() string {
if strings.Contains(e.FullKey, "/") {
return e.FullKey[:strings.Index(e.FullKey, "/")]
}
return e.FullKey
}
func (e Entry) Completed() bool {
mustHaveValue := []string{
e.Dataset.DiscordUsername,
e.Dataset.Name,
e.Dataset.ActivityEscapeRoom,
e.Dataset.ActivityKaraoke,
e.Dataset.ActivityMeanGirlsTheMusical,
e.Dataset.ActivityPub,
e.Dataset.DateSat31August,
e.Dataset.DateSat7September,
}
for _, v := range mustHaveValue {
if v == "" {
return false
}
}
return true
}
func (d *DB) List(ctx context.Context) ([]Entry, error) {
rows, err := d.db.QueryContext(ctx, "SELECT key, responses FROM responses ORDER BY key")
if err != nil {
return nil, fmt.Errorf("querying database: %w", err)
}
var entries []Entry
for rows.Next() {
var (
fullKey string
dataset Dataset
)
if err := rows.Scan(&fullKey, &dataset); err != nil {
return nil, fmt.Errorf("scanning row from database: %w", err)
}
entry := Entry{
FullKey: fullKey,
Dataset: dataset,
}
entries = append(entries, entry)
}
return entries, nil
}
func (d *DB) GetOrCreateKey(ctx context.Context, baseKey string, initialDataset *Dataset) (string, error) {
var existingKey string
err := d.db.QueryRowContext(ctx, `SELECT key FROM responses WHERE key LIKE ? || '/%'`, baseKey).Scan(&existingKey)
if err == nil {
return existingKey, nil
} else if errors.Is(err, sql.ErrNoRows) {
// This is fine, we'll create it.
} else {
return "", fmt.Errorf("querying database for existing keys for base %q: %w", baseKey, err)
}
randomKeyBytes := make([]byte, keyLength)
if _, err := rand.Read(randomKeyBytes); err != nil {
return "", fmt.Errorf("generating random key: %w", err)
}
randomKeyStringBytes := make([]byte, keyLength)
for n, b := range randomKeyBytes {
randomKeyStringBytes[n] = keyBytes[int(b)%len(keyBytes)]
}
randomKey := string(randomKeyStringBytes)
fullKey := baseKey + "/" + randomKey
if _, err := d.db.ExecContext(ctx, `INSERT INTO responses (key, responses) VALUES (?, ?)`, fullKey, initialDataset); err != nil {
return "", fmt.Errorf("inserting into responses table: %w", err)
}
return fullKey, nil
}
func (d *DB) GetResponseForShortKey(ctx context.Context, shortKey string) (*Dataset, error) {
var response Dataset
err := d.db.QueryRowContext(ctx, `SELECT responses FROM responses WHERE key = ? OR key LIKE ? || '/%'`, shortKey, shortKey).Scan(&response)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoSuchKey
} else if err != nil {
return nil, fmt.Errorf("fetching %v from database: %w", shortKey, err)
}
return &response, nil
}
func (d *DB) GetResponseForKey(ctx context.Context, key string) (*Dataset, error) {
var response Dataset
err := d.db.QueryRowContext(ctx, `SELECT responses FROM responses WHERE key = ?`, key).Scan(&response)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoSuchKey
} else if err != nil {
return nil, fmt.Errorf("fetching %v from database: %w", key, err)
}
return &response, nil
}
func (d *DB) SaveResponseForKey(ctx context.Context, key string, dataset *Dataset) error {
res, err := d.db.ExecContext(ctx, `UPDATE responses SET responses = ? WHERE key = ?`, dataset, key)
if err != nil {
return fmt.Errorf("updating database for %v: %w", key, err)
}
rowCount, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("determining affected row count for %v: %w", key, err)
} else if rowCount == 0 {
return ErrNoSuchKey
}
return nil
}
func NewDB(db *sql.DB) *DB {
return &DB{db: db}
}

View file

@ -0,0 +1,166 @@
package barfdb
import (
"context"
"database/sql"
"testing"
"github.com/google/go-cmp/cmp"
_ "github.com/mattn/go-sqlite3"
)
func makeDB(t *testing.T) *DB {
t.Helper()
sdb, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("creating inmemory sqlite3 database: %v", err)
}
t.Cleanup(func() { sdb.Close() })
db := &DB{sdb}
if err := db.Initialize(context.Background()); err != nil {
t.Fatalf("initializing DB: %v", err)
}
return &DB{sdb}
}
func TestGetOrCreateKey(t *testing.T) {
db := makeDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ds := Dataset{
Name: "Slartibartfast",
}
key, err := db.GetOrCreateKey(ctx, "testkey", &ds)
if err != nil {
t.Fatalf("GetOrCreateKey (first time): %v", err)
}
key2, err := db.GetOrCreateKey(ctx, "testkey", &ds)
if err != nil {
t.Fatalf("GetOrCreateKey (second time): %v", err)
}
if key != key2 {
t.Errorf("GetOrCreateKey returned different results for 'testkey': %v != %v", key, key2)
}
gotds, err := db.GetResponseForKey(ctx, key)
if err != nil {
t.Fatalf("GetResponseForKey(%q): %v", key, err)
}
if diff := cmp.Diff(*gotds, ds); diff != "" {
t.Errorf("GetResponseForKey diff (-got +want):\n%s", diff)
}
}
func TestSaveResponseForKey(t *testing.T) {
db := makeDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
key, err := db.GetOrCreateKey(ctx, "testkey", &Dataset{})
if err != nil {
t.Fatalf("GetOrCreateKey: %v", err)
}
ds := Dataset{
Name: "Slartibartfast",
}
if err := db.SaveResponseForKey(ctx, key, &ds); err != nil {
t.Fatalf("SaveResponseForKey: %v", err)
}
gotds, err := db.GetResponseForKey(ctx, key)
if err != nil {
t.Fatalf("GetResponseForKey: %v", err)
}
if diff := cmp.Diff(*gotds, ds); diff != "" {
t.Errorf("GetResponseForKey diff (-got +want):\n%s", diff)
}
}
func TestSaveResponseForKey_DoesNotExist(t *testing.T) {
db := makeDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ds := Dataset{
Name: "Slartibartfast",
}
if err := db.SaveResponseForKey(ctx, "testkey/foo", &ds); err != ErrNoSuchKey {
t.Fatalf("SaveResponseForKey: %v (want ErrNoSuchKey)", err)
}
}
func TestGetResponseForKey_DoesNotExist(t *testing.T) {
db := makeDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if _, err := db.GetResponseForKey(ctx, "testkey/foo"); err != ErrNoSuchKey {
t.Fatalf("GetResponseForKey: %v (want ErrNoSuchKey)", err)
}
}
func TestBaseKey(t *testing.T) {
for _, tc := range []struct {
FullKey string
Want string
}{{
FullKey: "foo",
Want: "foo",
}, {
FullKey: "foo/bar",
Want: "foo",
}, {
FullKey: "foo/bar/baz",
Want: "foo",
}} {
e := Entry{FullKey: tc.FullKey}
got := e.BaseKey()
if got != tc.Want {
t.Errorf("Entry{FullKey: %q}.BaseKey() = %q; want %q", tc.FullKey, got, tc.Want)
}
}
}
func TestList(t *testing.T) {
db := makeDB(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
keys := map[string]string{}
datasets := map[string]*Dataset{}
mustGetOrCreateKey := func(key string, ds *Dataset) {
fullKey, err := db.GetOrCreateKey(ctx, key, ds)
if err != nil {
t.Fatalf("GetOrCreateKey(ctx, %q, ds): %v", key, err)
}
keys[key] = fullKey
datasets[key] = ds
}
mustGetOrCreateKey("2-foo", &Dataset{Name: "A"})
mustGetOrCreateKey("1-fully-complete", &Dataset{
Name: "B Fully Complete",
})
want := []Entry{{
FullKey: keys["1-fully-complete"],
Dataset: *datasets["1-fully-complete"],
}, {
FullKey: keys["2-foo"],
Dataset: *datasets["2-foo"],
}}
es, err := db.List(ctx)
if err != nil {
t.Fatalf("List(ctx): %v", err)
}
if diff := cmp.Diff(es, want); diff != "" {
t.Errorf("List(ctx): has diff (-want +got):\n%s", diff)
}
}

View file

@ -6,6 +6,27 @@
pkgs.buildGoModule {
name = "barf-fe";
src = lib.sourceByRegex ./. [".*\.go$" "go.mod" "go.sum" "static" "static/clipit" "static/fonts" ".*/.*\.webm" ".*/.*\.png" ".*/.*\.wav" ".*/.*\.svg" ".*\.html" ".*/.*\.woff" ".*/.*\.woff2" ".*/.*\.css"];
vendorHash = "sha256:0w1k1ykga70af3643lky701kf27pfmgc3lhznfq1v32ww365w57f";
src = lib.sourceByRegex ./. [
".*\.go$"
"go.mod"
"go.sum"
"barfdb"
"barfcli"
"static"
"static/clipit"
"static/fonts"
".*/.*\.webm"
".*/.*\.png"
".*/.*\.wav"
".*/.*\.svg"
".*\.html"
".*/.*\.woff"
".*/.*\.woff2"
".*/.*\.css"
];
vendorHash = "sha256:0caif1kkxycdqpcp593y6dimwpwh7qcngncv360qb7m9a3ikh82y";
postInstall = ''
mv $out/bin/frontend $out/bin/barffe
'';
}

View file

@ -14,6 +14,8 @@ import (
"net/http/httputil"
"net/url"
"hg.lukegb.com/lukegb/depot/web/barf/frontend/barfdb"
_ "github.com/mattn/go-sqlite3"
)
@ -30,7 +32,7 @@ var indexTmplBytes []byte
var staticFS embed.FS
type application struct {
db *sql.DB
db *barfdb.DB
indexTmpl *template.Template
}
@ -68,42 +70,27 @@ func (a *application) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
http.Error(rw, "reading body failed", http.StatusInternalServerError)
return
}
var response dataset
var response barfdb.Dataset
if err := json.Unmarshal(body, &response); err != nil {
log.Printf("failed to unmarshal PUT dataset: %v", err)
http.Error(rw, "unmarshalling body failed", http.StatusBadRequest)
return
}
cleanedBody, err := json.Marshal(response)
if err != nil {
log.Printf("failed to remarshal PUT dataset: %v", err)
http.Error(rw, "remarshalling body failed", http.StatusBadRequest)
return
}
res, err := a.db.ExecContext(r.Context(), `UPDATE responses SET responses = ? WHERE key = ?`, string(cleanedBody), key)
if err != nil {
log.Printf("failed to update DB: %v", err)
http.Error(rw, "updating database failed", http.StatusInternalServerError)
return
}
rowCount, err := res.RowsAffected()
if err != nil {
log.Printf("determining affected rows: %v", err)
http.Error(rw, "updating database failed", http.StatusInternalServerError)
return
} else if rowCount == 0 {
log.Printf("no rows affected for key %v", key)
if err := a.db.SaveResponseForKey(r.Context(), key, &response); errors.Is(err, barfdb.ErrNoSuchKey) {
rw.WriteHeader(http.StatusNotFound)
fmt.Fprintf(rw, "Sorry, you need a key to access this application.\n")
return
} else if err != nil {
log.Printf("SaveResponseKeyFor %v: %v", key, err)
http.Error(rw, "saving response failed", http.StatusInternalServerError)
return
}
rw.WriteHeader(http.StatusNoContent)
return
}
var responseJSON *string
err := a.db.QueryRowContext(r.Context(), `select responses from responses where key = ?`, key).Scan(&responseJSON)
if errors.Is(err, sql.ErrNoRows) {
response, err := a.db.GetResponseForKey(r.Context(), key)
if errors.Is(err, barfdb.ErrNoSuchKey) {
rw.WriteHeader(http.StatusNotFound)
fmt.Fprintf(rw, "Sorry, you need a key to access this application.\n")
return
@ -113,29 +100,18 @@ func (a *application) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
return
}
var responseJSONStr string
if responseJSON == nil {
responseJSONStr = "{}"
} else {
var response dataset
if err := json.Unmarshal([]byte(*responseJSON), &response); err != nil {
log.Printf("database key %v corrupt, not valid json?: %v", key, err)
http.Error(rw, "response data corrupt!", http.StatusInternalServerError)
return
}
if response.CurrentPhase >= 6 {
// Make sure people who have saved _completion_ start at the beginning.
response.CurrentPhase = 0
response.CurrentPhaseDialog = 0
}
responseJSONBytes, err := json.Marshal(response)
if err != nil {
log.Printf("couldn't reserialise database key %v: %v", key, err)
http.Error(rw, "something went wrong with reserialisation...", http.StatusInternalServerError)
return
}
responseJSONStr = string(responseJSONBytes)
if response.CurrentPhase >= 6 {
// Make sure people who have saved _completion_ start at the beginning.
response.CurrentPhase = 0
response.CurrentPhaseDialog = 0
}
responseJSONBytes, err := json.Marshal(response)
if err != nil {
log.Printf("couldn't reserialise database key %v: %v", key, err)
http.Error(rw, "something went wrong with reserialisation...", http.StatusInternalServerError)
return
}
responseJSONStr := string(responseJSONBytes)
rw.Header().Set("Content-type", "text/html")
a.indexTmpl.Execute(rw, template.JS(responseJSONStr))
@ -149,11 +125,12 @@ func main() {
log.Fatalf("parsing index template: %v", err)
}
db, err := sql.Open("sqlite3", *dbPath)
sdb, err := sql.Open("sqlite3", *dbPath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
defer sdb.Close()
db := barfdb.New(sdb)
app := &application{db: db, indexTmpl: indexTmpl}
@ -163,7 +140,7 @@ func main() {
samBackendPath, err := url.Parse(*samBackend)
if err != nil {
log.Fatalf("parsing -sam_backend=%q: %w", *samBackend, err)
log.Fatalf("parsing -sam_backend=%q: %v", *samBackend, err)
}
be := httputil.NewSingleHostReverseProxy(samBackendPath)
http.Handle("/sam", be)

View file

@ -2,4 +2,13 @@ module hg.lukegb.com/lukegb/depot/web/barf/frontend
go 1.21.7
require github.com/mattn/go-sqlite3 v1.14.22
require (
github.com/google/go-cmp v0.6.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/spf13/cobra v1.8.0
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

View file

@ -1,2 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -14,7 +14,7 @@ html, body, #page {
}
body {
background-color: #003399;
font-family: Tahoma;
font-family: 'Tahoma';
user-select: none;
}
@ -373,7 +373,7 @@ body.all-done .now-safe {
}
.dialog-btn {
box-sizing: border-box;
font-family: Tahoma;
font-family: 'Tahoma';
padding: 0.2em 1.8em;
background-color: #d8d0cc;
@ -398,7 +398,7 @@ body.all-done .now-safe {
width: 100%;
min-height: 6rem;
resize: vertical;
font-family: Tahoma;
font-family: 'Tahoma';
}
.if-on-mobile {
display: none;