Compare commits

..

3 Commits

Author SHA1 Message Date
yequari e54875f943 add user login 2024-11-11 12:55:01 -07:00
yequari 741b304032 create and retrieve handlers, basic templates and styling, sessions 2024-10-22 22:25:30 -07:00
yequari 4fc9bb5c1f initial commit 2024-10-15 21:34:57 -07:00
29 changed files with 1859 additions and 0 deletions

7
.gitignore vendored
View File

@ -21,3 +21,10 @@
# Go workspace file
go.work
# sqlite3 databases
*.db
# air config
.air.toml
/tmp
tls/

6
cmd/web/context.go Normal file
View File

@ -0,0 +1,6 @@
package main
type contextKey string
const isAuthenticatedContextKey = contextKey("isAuthenticated")
const userNameContextKey = contextKey("username")

314
cmd/web/handlers.go Normal file
View File

@ -0,0 +1,314 @@
package main
import (
"errors"
"fmt"
"net/http"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
"git.32bit.cafe/32bitcafe/guestbook/internal/validator"
)
func (app *application) home(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
app.render(w, r, http.StatusOK, "home.tmpl.html", data)
}
type userRegistrationForm struct {
Name string `schema:"username"`
Email string `schema:"email"`
Password string `schema:"password"`
validator.Validator `schema:"-"`
}
func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userRegistrationForm{}
app.render(w, r, http.StatusOK, "usercreate.view.tmpl.html", data)
}
func (app *application) postUserRegister(w http.ResponseWriter, r *http.Request) {
var form userRegistrationForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 characters long")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "usercreate.view.tmpl.html", data)
return
}
shortId := app.createShortId()
err = app.users.Insert(shortId, form.Name, form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrDuplicateEmail) {
form.AddFieldError("email", "Email address is already in use")
data := app.newTemplateData(r)
data.Form = form
app.render(w ,r, http.StatusUnprocessableEntity, "usercreate.view.tmpl.html", data)
} else {
app.serverError(w, r, err)
}
return
}
app.sessionManager.Put(r.Context(), "flash", "Registration successful. Please log in.")
http.Redirect(w, r, "/users/login", http.StatusSeeOther)
}
type userLoginForm struct {
Email string `schema:"email"`
Password string `schema:"password"`
validator.Validator `schema:"-"`
}
func (app *application) getUserLogin(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userLoginForm{}
app.render(w, r, http.StatusOK, "login.view.tmpl.html", data)
}
func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) {
var form userLoginForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
}
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = userLoginForm{}
app.render(w, r, http.StatusUnprocessableEntity, "login.view.tmpl.html", data)
return
}
id, err := app.users.Authenticate(form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrInvalidCredentials) {
form.AddNonFieldError("Email or password is incorrect")
data := app.newTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "login.view.tmpl.html", data)
} else {
app.serverError(w, r, err)
}
return
}
err = app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "authenticatedUserId", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) {
err := app.sessionManager.RenewToken(r.Context())
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Remove(r.Context(), "authenticatedUserId")
app.sessionManager.Put(r.Context(), "flash", "You've been logged out successfully!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) getUsersList(w http.ResponseWriter, r *http.Request) {
users, err := app.users.GetAll()
if err != nil {
app.serverError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Users = users
app.render(w, r, http.StatusOK, "userlist.view.tmpl.html", data)
}
func (app *application) getUser(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("id")
user, err := app.users.Get(slugToShortId(slug))
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
data := app.newTemplateData(r)
data.User = user
app.render(w, r, http.StatusOK, "user.view.tmpl.html", data)
}
func (app *application) getGuestbookCreate(w http.ResponseWriter, r* http.Request) {
data := app.newTemplateData(r)
app.render(w, r, http.StatusOK, "guestbookcreate.view.tmpl.html", data)
}
func (app *application) postGuestbookCreate(w http.ResponseWriter, r* http.Request) {
err := r.ParseForm()
if err != nil {
app.serverError(w, r, err)
return
}
siteUrl := r.Form.Get("siteurl")
shortId := app.createShortId()
_, err = app.guestbooks.Insert(shortId, siteUrl, 0)
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "flash", "Guestbook successfully created!")
http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", shortIdToSlug(shortId)), http.StatusSeeOther)
}
func (app *application) getGuestbookList(w http.ResponseWriter, r *http.Request) {
userId := app.sessionManager.GetInt64(r.Context(), "authenticatedUserId")
guestbooks, err := app.guestbooks.GetAll(userId)
if err != nil {
app.serverError(w, r, err)
return
}
user, err := app.users.GetById(userId)
if err != nil {
app.serverError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Guestbooks = guestbooks
data.User = user
app.render(w, r, http.StatusOK, "guestbooklist.view.tmpl.html", data)
}
func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("id")
guestbook, err := app.guestbooks.Get(slugToShortId(slug))
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
comments, err := app.guestbookComments.GetAll(guestbook.ID)
if err != nil {
app.serverError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Guestbook = guestbook
data.Comments = comments
app.render(w, r, http.StatusOK, "guestbook.view.tmpl.html", data)
}
func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("id")
guestbook, err := app.guestbooks.Get(slugToShortId(slug))
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
comments, err := app.guestbookComments.GetAll(guestbook.ID)
if err != nil {
app.serverError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Guestbook = guestbook
data.Comments = comments
app.render(w, r, http.StatusOK, "commentlist.view.tmpl.html", data)
}
type commentCreateForm struct {
AuthorName string `schema:"authorname"`
AuthorEmail string `schema:"authoremail"`
AuthorSite string `schema:"authorsite"`
Content string `schema:"content,required"`
validator.Validator `schema:"-"`
}
func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("id")
guestbook, err := app.guestbooks.Get(slugToShortId(slug))
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
data := app.newTemplateData(r)
data.Guestbook = guestbook
data.Form = commentCreateForm{}
app.render(w, r, http.StatusOK, "commentcreate.view.tmpl.html", data)
}
func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) {
guestbookSlug := r.PathValue("id")
guestbook, err := app.guestbooks.Get(slugToShortId(guestbookSlug))
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
var form commentCreateForm
err = app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.AuthorName), "authorName", "This field cannot be blank")
form.CheckField(validator.MaxChars(form.AuthorName, 256), "authorName", "This field cannot be more than 256 characters long")
form.CheckField(validator.NotBlank(form.AuthorEmail), "authorEmail", "This field cannot be blank")
form.CheckField(validator.MaxChars(form.AuthorEmail, 256), "authorEmail", "This field cannot be more than 256 characters long")
form.CheckField(validator.NotBlank(form.AuthorSite), "authorSite", "This field cannot be blank")
form.CheckField(validator.MaxChars(form.AuthorSite, 256), "authorSite", "This field cannot be more than 256 characters long")
form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
if !form.Valid() {
data := app.newTemplateData(r)
data.Guestbook = guestbook
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "commentcreate.view.tmpl.html", data)
return
}
shortId := app.createShortId()
_, err = app.guestbookComments.Insert(shortId, guestbook.ID, 0, form.AuthorName, form.AuthorEmail, form.AuthorSite, form.Content, "", true)
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "flash", "Comment successfully posted!")
http.Redirect(w, r, fmt.Sprintf("/guestbooks/%s", guestbookSlug), http.StatusSeeOther)
}

99
cmd/web/helpers.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"errors"
"fmt"
"math"
"net/http"
"strconv"
"time"
"github.com/gorilla/schema"
)
func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) {
var (
method = r.Method
uri = r.URL.RequestURI()
)
app.logger.Error(err.Error(), "method", method, "uri", uri)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
func (app *application) clientError(w http.ResponseWriter, status int) {
http.Error(w, http.StatusText(status), status)
}
func (app *application) render(w http.ResponseWriter, r *http.Request, status int, page string, data templateData) {
ts, ok := app.templateCache[page]
if !ok {
err := fmt.Errorf("the template %s does not exist", page)
app.serverError(w, r, err)
return
}
w.WriteHeader(status)
err := ts.ExecuteTemplate(w, "base", data)
if err != nil {
app.serverError(w, r, err)
}
}
func (app *application) nextSequence () uint16 {
val := app.sequence
if app.sequence == math.MaxUint16 {
app.sequence = 0
} else {
app.sequence += 1
}
return val
}
func (app *application) createShortId () uint64 {
now := time.Now().UTC()
epoch, err := time.Parse(time.RFC822Z, "01 Jan 20 00:00 -0000")
if err != nil {
fmt.Println(err)
return 0
}
d := now.Sub(epoch)
ms := d.Milliseconds()
seq := app.nextSequence()
return (uint64(ms) & 0x0FFFFFFFFFFFFFFF) | (uint64(seq) << 48)
}
func shortIdToSlug(id uint64) string {
slug := strconv.FormatUint(id, 36)
return slug
}
func slugToShortId(slug string) uint64 {
id, _ := strconv.ParseUint(slug, 36, 64)
return id
}
func (app *application) decodePostForm(r *http.Request, dst any) error {
err := r.ParseForm()
if err != nil {
return err
}
err = app.formDecoder.Decode(dst, r.PostForm)
if err != nil {
var multiErrors *schema.MultiError
if !errors.As(err, &multiErrors) {
panic(err)
}
return err
}
return nil
}
func (app *application) isAuthenticated(r *http.Request) bool {
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
if !ok {
return false
}
return isAuthenticated
}

103
cmd/web/main.go Normal file
View File

@ -0,0 +1,103 @@
package main
import (
"crypto/tls"
"database/sql"
"flag"
"log/slog"
"net/http"
"os"
"text/template"
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
"github.com/alexedwards/scs/sqlite3store"
"github.com/alexedwards/scs/v2"
"github.com/gorilla/schema"
_ "github.com/mattn/go-sqlite3"
)
type application struct {
sequence uint16
logger *slog.Logger
templateCache map[string]*template.Template
guestbooks *models.GuestbookModel
users *models.UserModel
guestbookComments *models.GuestbookCommentModel
sessionManager *scs.SessionManager
formDecoder *schema.Decoder
}
func main() {
addr := flag.String("addr", ":3000", "HTTP network address")
dsn := flag.String("dsn", "guestbook.db", "data source name")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
db, err := openDB(*dsn)
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
defer db.Close()
templateCache, err := newTemplateCache()
if err != nil {
logger.Error(err.Error())
os.Exit(1)
}
sessionManager := scs.New()
sessionManager.Store = sqlite3store.New(db)
sessionManager.Lifetime = 12 * time.Hour
formDecoder := schema.NewDecoder()
formDecoder.IgnoreUnknownKeys(true)
app := &application{
sequence: 0,
templateCache: templateCache,
logger: logger,
sessionManager: sessionManager,
guestbooks: &models.GuestbookModel{DB: db},
users: &models.UserModel{DB: db},
guestbookComments: &models.GuestbookCommentModel{DB: db},
formDecoder: formDecoder,
}
tlsConfig := &tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}
srv := &http.Server {
Addr: *addr,
Handler: app.routes(),
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError),
TLSConfig: tlsConfig,
IdleTimeout: time.Minute,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
logger.Info("Starting server", slog.Any("addr", *addr))
err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
logger.Error(err.Error())
os.Exit(1)
}
func openDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
if err = db.Ping(); err != nil {
return nil, err
}
return db, nil
}
func getUserId() int64 {
return 1
}

88
cmd/web/middleware.go Normal file
View File

@ -0,0 +1,88 @@
package main
import (
"context"
"fmt"
"net/http"
"github.com/justinas/nosurf"
)
func (app *application) logRequest (next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
ip = r.RemoteAddr
proto = r.Proto
method = r.Method
uri = r.URL.RequestURI()
)
app.logger.Info("received request", "ip", ip, "proto", proto, "method", method, "uri", uri)
next.ServeHTTP(w, r)
})
}
func commonHeaders (next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com")
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
// w.Header().Set("X-Frame-Options", "deny")
w.Header().Set("X-XSS-Protection", "0")
next.ServeHTTP(w, r)
})
}
func (app *application) recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
w.Header().Set("Connection", "close")
app.serverError(w, r, fmt.Errorf("%s", err))
}
}()
next.ServeHTTP(w, r)
})
}
func (app *application) requireAuthentication(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !app.isAuthenticated(r) {
http.Redirect(w, r, "/users/login", http.StatusSeeOther)
return
}
w.Header().Add("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}
func noSurf(next http.Handler) http.Handler {
csrfHandler := nosurf.New(next)
csrfHandler.SetBaseCookie(http.Cookie{
HttpOnly: true,
Path: "/",
Secure: true,
})
return csrfHandler
}
func (app *application) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := app.sessionManager.GetInt64(r.Context(), "authenticatedUserId")
if id == 0 {
next.ServeHTTP(w, r)
return
}
exists, err := app.users.Exists(id)
if err != nil {
app.serverError(w, r, err)
return
}
if exists {
ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
r = r.WithContext(ctx)
}
next.ServeHTTP(w, r)
})
}

38
cmd/web/routes.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"net/http"
"github.com/justinas/alice"
)
func (app *application) routes() http.Handler {
mux := http.NewServeMux()
fileServer := http.FileServer(http.Dir("./ui/static"))
mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
mux.Handle("/{$}", dynamic.ThenFunc(app.home))
mux.Handle("POST /guestbooks/{id}/comments/create", dynamic.ThenFunc(app.postGuestbookCommentCreate))
mux.Handle("GET /guestbooks/{id}", dynamic.ThenFunc(app.getGuestbook))
mux.Handle("GET /users/register", dynamic.ThenFunc(app.getUserRegister))
mux.Handle("POST /users/register", dynamic.ThenFunc(app.postUserRegister))
mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin))
mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin))
protected := dynamic.Append(app.requireAuthentication)
mux.Handle("GET /users", protected.ThenFunc(app.getUsersList))
mux.Handle("GET /users/{id}", protected.ThenFunc(app.getUser))
mux.Handle("POST /users/logout", protected.ThenFunc(app.postUserLogout))
mux.Handle("GET /guestbooks", protected.ThenFunc(app.getGuestbookList))
mux.Handle("GET /guestbooks/create", protected.ThenFunc(app.getGuestbookCreate))
mux.Handle("POST /guestbooks/create", protected.ThenFunc(app.postGuestbookCreate))
mux.Handle("GET /guestbooks/{id}/comments/create", protected.ThenFunc(app.getGuestbookCommentCreate))
standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)
return standard.Then(mux)
}

69
cmd/web/templates.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"net/http"
"path/filepath"
"text/template"
"time"
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
"github.com/justinas/nosurf"
)
type templateData struct {
CurrentYear int
User models.User
Users []models.User
Guestbook models.Guestbook
Guestbooks []models.Guestbook
Comment models.GuestbookComment
Comments []models.GuestbookComment
Flash string
Form any
IsAuthenticated bool
CSRFToken string
}
func humanDate(t time.Time) string {
return t.Format("02 Jan 2006 at 15:04")
}
var functions = template.FuncMap {
"humanDate": humanDate,
"shortIdToSlug": shortIdToSlug,
"slugToShortId": slugToShortId,
}
func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}
pages, err := filepath.Glob("./ui/html/pages/*.tmpl.html")
if err != nil {
return nil, err
}
for _, page := range pages {
name := filepath.Base(page)
ts, err := template.New(name).Funcs(functions).ParseFiles("./ui/html/base.tmpl.html")
if err != nil {
return nil, err
}
ts, err = ts.ParseGlob("./ui/html/partials/*.tmpl.html")
if err != nil {
return nil, err
}
ts, err = ts.ParseFiles(page)
if err != nil {
return nil, err
}
cache[name] = ts
}
return cache, nil
}
func (app *application) newTemplateData(r *http.Request) templateData {
return templateData {
CurrentYear: time.Now().Year(),
Flash: app.sessionManager.PopString(r.Context(), "flash"),
IsAuthenticated: app.isAuthenticated(r),
CSRFToken: nosurf.Token(r),
}
}

View File

@ -0,0 +1,5 @@
CREATE TABLE sessions (
token CHAR(43) primary key,
data BLOB NOT NULL,
expiry TEXT NOT NULL
);

View File

@ -0,0 +1,46 @@
CREATE TABLE users (
Id integer primary key autoincrement,
ShortId integer UNIQUE NOT NULL,
Username varchar(32) NOT NULL,
Email varchar(256) UNIQUE NOT NULL,
IsDeleted boolean NOT NULL DEFAULT FALSE,
IsBanned boolean NOT NULL DEFAULT FALSE,
HashedPassword char(60) NOT NULL,
Created datetime NOT NULL
);
CREATE TABLE guestbooks (
Id integer primary key autoincrement,
ShortId integer UNIQUE NOT NULL,
SiteUrl varchar(512) NOT NULL,
UserId blob(16) NOT NULL,
Created datetime NOT NULL,
IsDeleted boolean NOT NULL DEFAULT FALSE,
IsActive boolean NOT NULL DEFAULT TRUE,
FOREIGN KEY (UserId) REFERENCES users(Id)
ON DELETE RESTRICT
ON UPDATE RESTRICT
);
CREATE TABLE guestbook_comments (
Id integer primary key autoincrement,
ShortId integer UNIQUE NOT NULL,
GuestbookId blob(16) NOT NULL,
ParentId blob(16),
AuthorName varchar(256) NOT NULL,
AuthorEmail varchar(256) NOT NULL,
AuthorSite varchar(256),
CommentText text NOT NULL,
PageUrl varchar(256),
Created datetime NOT NULL,
IsPublished boolean NOT NULL DEFAULT TRUE,
IsDeleted boolean NOT NULL DEFAULT FALSE,
FOREIGN KEY (GuestbookId)
REFERENCES guestbooks(Id)
ON DELETE RESTRICT
ON UPDATE RESTRICT,
FOREIGN KEY (ParentId)
REFERENCES guestbook_comments(Id)
ON DELETE RESTRICT
ON UPDATE RESTRICT
);

17
go.mod Normal file
View File

@ -0,0 +1,17 @@
module git.32bit.cafe/32bitcafe/guestbook
go 1.23.1
require (
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885
github.com/alexedwards/scs/v2 v2.8.0
github.com/google/uuid v1.6.0
github.com/mattn/go-sqlite3 v1.14.6
)
require (
github.com/gorilla/schema v1.4.1 // indirect
github.com/justinas/alice v1.2.0 // indirect
github.com/justinas/nosurf v1.1.1 // indirect
golang.org/x/crypto v0.29.0 // indirect
)

16
go.sum Normal file
View File

@ -0,0 +1,16 @@
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0=
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk=
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=

11
internal/models/errors.go Normal file
View File

@ -0,0 +1,11 @@
package models
import "errors"
var (
ErrNoRecord = errors.New("models: no matching record found")
ErrInvalidCredentials = errors.New("models: invalid credentials")
ErrDuplicateEmail = errors.New("models: duplicate email")
)

View File

@ -0,0 +1,69 @@
package models
import (
"database/sql"
"time"
)
type Guestbook struct {
ID int64
ShortId uint64
SiteUrl string
UserId int64
Created time.Time
IsDeleted bool
IsActive bool
}
type GuestbookModel struct {
DB *sql.DB
}
func (m *GuestbookModel) Insert(shortId uint64, siteUrl string, userId int64) (int64, error) {
stmt := `INSERT INTO guestbooks (ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive)
VALUES(?, ?, ?, ?, FALSE, TRUE)`
result, err := m.DB.Exec(stmt, shortId, siteUrl, userId, time.Now().UTC())
if err != nil {
return -1, err
}
id, err := result.LastInsertId()
if err != nil {
return -1, err
}
return id, nil
}
func (m *GuestbookModel) Get(shortId uint64) (Guestbook, error) {
stmt := `SELECT Id, ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive FROM guestbooks
WHERE ShortId = ?`
row := m.DB.QueryRow(stmt, shortId)
var g Guestbook
err := row.Scan(&g.ID, &g.ShortId, &g.SiteUrl, &g.UserId, &g.Created, &g.IsDeleted, &g.IsActive)
if err != nil {
return Guestbook{}, err
}
return g, nil
}
func (m *GuestbookModel) GetAll(userId int64) ([]Guestbook, error) {
stmt := `SELECT Id, ShortId, SiteUrl, UserId, Created, IsDeleted, IsActive FROM guestbooks
WHERE UserId = ?`
rows, err := m.DB.Query(stmt, userId)
if err != nil {
return nil, err
}
var guestbooks []Guestbook
for rows.Next() {
var g Guestbook
err = rows.Scan(&g.ID, &g.ShortId, &g.SiteUrl, &g.UserId, &g.Created, &g.IsDeleted, &g.IsActive)
if err != nil {
return nil, err
}
guestbooks = append(guestbooks, g)
}
if err = rows.Err(); err != nil {
return nil, err
}
return guestbooks, nil
}

View File

@ -0,0 +1,76 @@
package models
import (
"database/sql"
"time"
)
type GuestbookComment struct {
ID int64
ShortId uint64
GuestbookId int64
ParentId int64
AuthorName string
AuthorEmail string
AuthorSite string
CommentText string
PageUrl string
Created time.Time
IsPublished bool
IsDeleted bool
}
type GuestbookCommentModel struct {
DB *sql.DB
}
func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName,
authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) {
stmt := `INSERT INTO guestbook_comments (ShortId, GuestbookId, ParentId, AuthorName,
AuthorEmail, AuthorSite, CommentText, PageUrl, Created, IsPublished, IsDeleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)`
result, err := m.DB.Exec(stmt, shortId, guestbookId, parentId, authorName, authorEmail,
authorSite, commentText, pageUrl, time.Now().UTC(), isPublished)
if err != nil {
return -1, err
}
id, err := result.LastInsertId()
if err != nil {
return -1, err
}
return id, nil
}
func (m *GuestbookCommentModel) Get(shortId uint64) (GuestbookComment, error) {
stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
CommentText, PageUrl, Created, IsPublished, IsDeleted FROM guestbook_comments WHERE ShortId = ?`
row := m.DB.QueryRow(stmt, shortId)
var c GuestbookComment
err := row.Scan(&c.ID, &c.ShortId, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.Created, &c.IsPublished, &c.IsDeleted)
if err != nil {
return GuestbookComment{}, err
}
return c, nil
}
func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]GuestbookComment, error) {
stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
CommentText, PageUrl, Created, IsPublished, IsDeleted FROM guestbook_comments WHERE GuestbookId = ? AND IsDeleted = FALSE ORDER BY Created DESC`
rows, err := m.DB.Query(stmt, guestbookId)
if err != nil {
return nil, err
}
var comments []GuestbookComment
for rows.Next() {
var c GuestbookComment
err = rows.Scan(&c.ID, &c.ShortId, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.Created, &c.IsPublished, &c.IsDeleted)
if err != nil {
return nil, err
}
comments = append(comments, c)
}
if err = rows.Err(); err != nil {
return nil, err
}
return comments, nil
}

124
internal/models/user.go Normal file
View File

@ -0,0 +1,124 @@
package models
import (
"database/sql"
"errors"
"strings"
"time"
"github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID int
ShortId uint64
Username string
Email string
IsDeleted bool
IsBanned bool
HashedPassword []byte
Created time.Time
}
type UserModel struct {
DB *sql.DB
}
func (m *UserModel) Insert(shortId uint64, username string, email string, password string) error {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return err
}
stmt := `INSERT INTO users (ShortId, Username, Email, IsDeleted, IsBanned, HashedPassword, Created)
VALUES (?, ?, ?, FALSE, FALSE, ?, ?)`
_, err = m.DB.Exec(stmt, shortId, username, email, hashedPassword, time.Now().UTC())
if err != nil {
if sqliteError, ok := err.(sqlite3.Error); ok {
if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") {
return ErrDuplicateEmail
}
}
return err
}
return nil
}
func (m *UserModel) Get(id uint64) (User, error) {
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND IsDeleted = FALSE`
row := m.DB.QueryRow(stmt, id)
var u User
err := row.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNoRecord
}
return User{}, err
}
return u, nil
}
func (m *UserModel) GetById(id int64) (User, error) {
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE Id = ? AND IsDeleted = FALSE`
row := m.DB.QueryRow(stmt, id)
var u User
err := row.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNoRecord
}
return User{}, err
}
return u, nil
}
func (m *UserModel) GetAll() ([]User, error) {
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE IsDeleted = FALSE`
rows, err := m.DB.Query(stmt)
var users []User
for rows.Next() {
var u User
err = rows.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created)
if err != nil {
return nil, err
}
users = append(users, u)
}
if err = rows.Err(); err != nil {
return nil, err
}
return users, nil
}
func (m *UserModel) Authenticate(email, password string) (int64, error) {
var id int64
var hashedPassword []byte
stmt := `SELECT Id, HashedPassword FROM users WHERE Email = ?`
err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, ErrInvalidCredentials
} else {
return 0, err
}
}
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
if err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return 0, ErrInvalidCredentials
} else {
return 0, err
}
}
return id, nil
}
func (m *UserModel) Exists(id int64) (bool, error) {
var exists bool
stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND IsDeleted = False)`
err := m.DB.QueryRow(stmt, id).Scan(&exists)
return exists, err
}

View File

@ -0,0 +1,58 @@
package validator
import (
"regexp"
"slices"
"strings"
"unicode/utf8"
)
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
type Validator struct {
NonFieldErrors []string
FieldErrors map[string]string
}
func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
}
func (v *Validator) AddFieldError(key, message string) {
if v.FieldErrors == nil {
v.FieldErrors = make(map[string]string)
}
if _, exists := v.FieldErrors[key]; !exists {
v.FieldErrors[key] = message
}
}
func (v *Validator) AddNonFieldError(message string) {
v.NonFieldErrors = append(v.NonFieldErrors, message)
}
func (v *Validator) CheckField(ok bool, key, message string) {
if !ok {
v.AddFieldError(key, message)
}
}
func NotBlank(value string) bool {
return strings.TrimSpace(value) != ""
}
func MaxChars(value string, n int) bool {
return utf8.RuneCountInString(value) <= n
}
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
return slices.Contains(permittedValues, value)
}
func MinChars(value string, n int) bool {
return utf8.RuneCountInString(value) >= n
}
func Matches(value string, rx *regexp.Regexp) bool {
return rx.MatchString(value)
}

26
ui/html/base.tmpl.html Normal file
View File

@ -0,0 +1,26 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ template "title" }} - Guestbook</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/css/style.css" rel="stylesheet">
</head>
<body>
<header>
<h1><a href="/">Guestbook</a></h1>
</header>
{{ template "nav" . }}
<main>
{{ with .Flash }}
<div class="flash">{{ . }}</div>
{{ end }}
{{ template "main" . }}
</main>
<footer>
<p>A 32bit.cafe Project</p>
</footer>
</body>
</html>
{{ end }}

View File

@ -0,0 +1,36 @@
{{ define "title" }}New Comment{{ end }}
{{ define "main" }}
<form action="/guestbooks/{{ shortIdToSlug .Guestbook.ShortId }}/comments/create" method="post">
<div>
<label for="authorname">Name: </label>
{{ with .Form.FieldErrors.authorName }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="authorname" id="authorname" >
</div>
<div>
<label for="authoremail">Email: </label>
{{ with .Form.FieldErrors.authorEmail }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="authoremail" id="authoremail" >
</div>
<div>
<label for="authorsite">Site Url: </label>
{{ with .Form.FieldErrors.authorSite }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="authorsite" id="authorsite" >
</div>
<div>
<label for="content">Comment: </label>
{{ with .Form.FieldErrors.content }}
<label class="error">{{.}}</label>
{{ end }}
<textarea name="content" id="content"></textarea>
</div>
<div>
<input type="submit" value="Post">
</div>
</form>
{{ end }}

View File

@ -0,0 +1,18 @@
{{ define "title" }} Guestbook View {{ end }}
{{ define "main" }}
<h1>Guestbook for {{ .Guestbook.SiteUrl }}</h1>
<p>
<a href="/guestbooks/{{ shortIdToSlug .Guestbook.ShortId }}/comments/create">New Comment</a>
</p>
{{ range .Comments }}
<div>
<strong> {{ .AuthorName }} </strong>
{{ .Created.Local.Format "01-02-2006 03:04PM" }}
<p>
{{ .CommentText }}
</p>
</div>
{{ else }}
<p>No comments yet!</p>
{{ end }}
{{ end }}

View File

@ -0,0 +1,9 @@
{{ define "title" }}Create a Guestbook{{ end }}
{{ define "main" }}
<form action="/guestbooks/create" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="siteurl">Site URL: </label>
<input type="text" name="siteurl" id="siteurl" required />
<input type="submit" />
</form>
{{ end }}

View File

@ -0,0 +1,11 @@
{{ define "title" }} Guestbooks {{ end }}
{{ define "main" }}
<h1>Guestbooks run by {{ .User.Username }}</h1>
<ul>
{{ range .Guestbooks }}
<li><a href="/guestbooks/{{ shortIdToSlug .ShortId }}">{{ with .SiteUrl }}{{ . }}{{ else }}Untitled{{ end }}</a></li>
{{ else }}
<p>No Guestbooks yet</p>
{{ end }}
</ul>
{{ end }}

View File

@ -0,0 +1,10 @@
{{ define "title" }}Home{{ end }}
{{ define "main" }}
<h2>Latest Guestbooks</h2>
<p>
<a href="/guestbooks">View Guestbooks</a>
</p>
<p>
<a href="/guestbooks/create">Create a Guestbook</a>
</p>
{{ end }}

View File

@ -0,0 +1,26 @@
{{define "title"}}Login{{end}}
{{define "main"}}
<form action="/users/login" method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{ range .Form.NonFieldErrors }}
<div class="error">{{.}}</div>
{{ end }}
<div>
<label>Email: </label>
{{ with .Form.FieldErrors.email }}
<label class="error">{{.}}</label>
{{ end }}
<input type="email" name="email" value="{{.Form.Email}}">
</div>
<div>
<label>Password: </label>
{{ with .Form.FieldErrors.password }}
<label class="error">{{.}}</label>
{{ end }}
<input type="password" name="password">
</div>
<div>
<input type="submit" value="login">
</div>
</form>
{{end}}

View File

@ -0,0 +1,5 @@
{{ define "title" }}{{ .User.Username }}{{ end }}
{{ define "main" }}
<h1>{{ .User.Username }}</h1>
<p>{{ .User.Email }}</p>
{{ end }}

View File

@ -0,0 +1,30 @@
{{ define "title" }}User Registration{{ end }}
{{ define "main" }}
<form action="/users/register" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<div>
<label for="username">Username: </label>
{{ with .Form.FieldErrors.name }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="username" id="username" value="{{ .Form.Name }}" required />
</div>
<div>
<label for="email">Email: </label>
{{ with .Form.FieldErrors.email }}
<label class="error">{{.}}</label>
{{ end }}
<input type="text" name="email" id="email" value="{{ .Form.Email }}" required />
</div>
<div>
<label for="password">Password: </label>
{{ with .Form.FieldErrors.password }}
<label class="error">{{.}}</label>
{{ end }}
<input type="password" name="password" id="password" />
</div>
<div>
<input type="submit" value="Register"/>
</div>
</form>
{{ end }}

View File

@ -0,0 +1,9 @@
{{ define "title" }}Users{{ end }}
{{ define "main" }}
<h1>Users</h1>
{{ range .Users }}
<p>
<a href="/users/{{ shortIdToSlug .ShortId }}">{{ .Username }}</a>
</p>
{{ end }}
{{ end }}

View File

@ -0,0 +1,22 @@
{{ define "nav" }}
<nav>
<div>
<a href="/">Home</a>
<a href="/users">Users</a>
</div>
<div>
{{ if .IsAuthenticated }}
{{ with .User }}
<a href="/users/{{ .User.ShortId }}">{{ .User.Username }}</a>
{{ end }}
<form action="/users/logout" method="post">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button>Logout</button>
</form>
{{ else }}
<a href="/users/register">Register</a>
<a href="/users/login">Login</a>
{{ end }}
</div>
</nav>
{{ end }}

511
ui/static/css/style.css Normal file
View File

@ -0,0 +1,511 @@
/* Set the global variables for everything. Change these to use your own fonts and colours. */
:root {
/* Set sans-serif & mono fonts */
--sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir,
"Nimbus Sans L", Roboto, Noto, "Segoe UI", Arial, Helvetica,
"Helvetica Neue", sans-serif;
--mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
/* Body font size. By default, effectively 18.4px, based on 16px as 'root em' */
--base-fontsize: 1.15rem;
/* Major third scale progression - see https://type-scale.com/ */
--header-scale: 1.25;
/* Line height is set to the "Golden ratio" for optimal legibility */
--line-height: 1.618;
/* Default (light) theme */
--bg: #fff;
--accent-bg: #f5f7ff;
--text: #212121;
--text-light: #585858;
--border: #d8dae1;
--accent: #0d47a1;
--accent-light: #90caf9;
--code: #d81b60;
--preformatted: #444;
--marked: #ffdd33;
--disabled: #efefef;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--bg: #212121;
--accent-bg: #2b2b2b;
--text: #dcdcdc;
--text-light: #ababab;
--border: #666;
--accent: #ffb300;
--accent-light: #ffecb3;
--code: #f06292;
--preformatted: #ccc;
--disabled: #111;
}
img,
video {
opacity: 0.6;
}
}
html {
/* Set the font globally */
font-family: var(--sans-font);
}
/* Make the body a nice central block */
body {
color: var(--text);
background: var(--bg);
font-size: var(--base-fontsize);
line-height: var(--line-height);
display: flex;
min-height: 100vh;
flex-direction: column;
flex: 1;
margin: 0 auto;
max-width: 45rem;
padding: 0 0.5rem;
overflow-x: hidden;
word-break: break-word;
overflow-wrap: break-word;
}
/* Make the header bg full width, but the content inline with body */
header {
background: var(--accent-bg);
border-bottom: 1px solid var(--border);
text-align: center;
padding: 2rem 0.5rem;
width: 100vw;
position: relative;
box-sizing: border-box;
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
}
/* Remove margins for header text */
header h1,
header p {
margin: 0;
}
/* Add a little padding to ensure spacing is correct between content and nav */
main {
padding-top: 1.5rem;
}
/* Fix line height when title wraps */
h1,
h2,
h3 {
line-height: 1.1;
}
/* Format navigation */
nav {
font-size: 1rem;
line-height: 2;
padding: 1rem 0;
}
nav a {
margin: 1rem 1rem 0 0;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text) !important;
display: inline-block;
padding: 0.1rem 1rem;
text-decoration: none;
transition: 0.4s;
}
nav a:hover {
color: var(--accent) !important;
border-color: var(--accent);
}
nav a.current:hover {
text-decoration: none;
}
footer {
margin-top: 4rem;
padding: 2rem 1rem 1.5rem 1rem;
color: var(--text-light);
font-size: 0.9rem;
text-align: center;
border-top: 1px solid var(--border);
}
/* Format headers */
h1 {
font-size: calc(
var(--base-fontsize) * var(--header-scale) * var(--header-scale) *
var(--header-scale) * var(--header-scale)
);
margin-top: calc(var(--line-height) * 1.5rem);
}
h2 {
font-size: calc(
var(--base-fontsize) * var(--header-scale) * var(--header-scale) *
var(--header-scale)
);
margin-top: calc(var(--line-height) * 1.5rem);
}
h3 {
font-size: calc(
var(--base-fontsize) * var(--header-scale) * var(--header-scale)
);
margin-top: calc(var(--line-height) * 1.5rem);
}
h4 {
font-size: calc(var(--base-fontsize) * var(--header-scale));
margin-top: calc(var(--line-height) * 1.5rem);
}
h5 {
font-size: var(--base-fontsize);
margin-top: calc(var(--line-height) * 1.5rem);
}
h6 {
font-size: calc(var(--base-fontsize) / var(--header-scale));
margin-top: calc(var(--line-height) * 1.5rem);
}
/* Format links & buttons */
a,
a:visited {
color: var(--accent);
}
a:hover {
text-decoration: none;
}
a button,
button,
[role="button"],
input[type="submit"],
input[type="reset"],
input[type="button"] {
border: none;
border-radius: 5px;
background: var(--accent);
font-size: 1rem;
color: var(--bg);
padding: 0.7rem 0.9rem;
margin: 0.5rem 0;
transition: 0.4s;
}
a button[disabled],
button[disabled],
[role="button"][aria-disabled="true"],
input[type="submit"][disabled],
input[type="reset"][disabled],
input[type="button"][disabled],
input[type="checkbox"][disabled],
input[type="radio"][disabled],
select[disabled] {
cursor: default;
opacity: 0.5;
cursor: not-allowed;
}
input:disabled,
textarea:disabled,
select:disabled {
cursor: not-allowed;
background-color: var(--disabled);
}
input[type="range"] {
padding: 0;
}
/* Set the cursor to '?' while hovering over an abbreviation */
abbr {
cursor: help;
}
button:focus,
button:enabled:hover,
[role="button"]:focus,
[role="button"]:not([aria-disabled="true"]):hover,
input[type="submit"]:focus,
input[type="submit"]:enabled:hover,
input[type="reset"]:focus,
input[type="reset"]:enabled:hover,
input[type="button"]:focus,
input[type="button"]:enabled:hover,
input[type="checkbox"]:focus,
input[type="checkbox"]:enabled:hover,
input[type="radio"]:focus,
input[type="radio"]:enabled:hover {
filter: brightness(1.4);
cursor: pointer;
}
/* Format the expanding box */
details {
background: var(--accent-bg);
border: 1px solid var(--border);
border-radius: 5px;
margin-bottom: 1rem;
}
summary {
cursor: pointer;
font-weight: bold;
padding: 0.6rem 1rem;
}
details[open] {
padding: 0.6rem 1rem 0.75rem 1rem;
}
details[open] summary {
margin-bottom: 0.5rem;
padding: 0;
}
details[open] > *:last-child {
margin-bottom: 0;
}
/* Format tables */
table {
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
}
td,
th {
border: 1px solid var(--border);
text-align: left;
padding: 0.5rem;
}
th {
background: var(--accent-bg);
font-weight: bold;
}
tr:nth-child(even) {
/* Set every other cell slightly darker. Improves readability. */
background: var(--accent-bg);
}
table caption {
font-weight: bold;
margin-bottom: 0.5rem;
}
/* Lists */
ol,
ul {
padding-left: 3rem;
}
/* Format forms */
textarea,
select,
input {
font-size: inherit;
font-family: inherit;
padding: 0.5rem;
margin-bottom: 0.5rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
box-shadow: none;
box-sizing: border-box;
width: 60%;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}
/* Add arrow to */
select {
background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%),
linear-gradient(135deg, var(--text) 51%, transparent 49%);
background-position: calc(100% - 20px), calc(100% - 15px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
select[multiple] {
background-image: none !important;
}
/* checkbox and radio button style */
input[type="checkbox"],
input[type="radio"] {
vertical-align: bottom;
position: relative;
}
input[type="radio"] {
border-radius: 100%;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background: var(--accent);
}
input[type="checkbox"]:checked::after {
/* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */
content: " ";
width: 0.1em;
height: 0.25em;
border-radius: 0;
position: absolute;
top: 0.05em;
left: 0.18em;
background: transparent;
border-right: solid var(--bg) 0.08em;
border-bottom: solid var(--bg) 0.08em;
font-size: 1.8em;
transform: rotate(45deg);
}
input[type="radio"]:checked::after {
/* creates a colored circle for the checked radio button */
content: " ";
width: 0.25em;
height: 0.25em;
border-radius: 100%;
position: absolute;
top: 0.125em;
background: var(--bg);
left: 0.125em;
font-size: 32px;
}
/* Make the textarea wider than other inputs */
textarea {
width: 80%;
}
/* Makes input fields wider on smaller screens */
@media only screen and (max-width: 720px) {
textarea,
select,
input {
width: 100%;
}
}
/* Ensures the checkbox and radio inputs do not have a set width like other input fields */
input[type="checkbox"],
input[type="radio"] {
width: auto;
}
/* do not show border around file selector button */
input[type="file"] {
border: 0;
}
/* Without this any HTML using <fieldset> shows ugly borders and has additional padding/margin. (Issue #3) */
fieldset {
border: 0;
padding: 0;
margin: 0;
}
/* Misc body elements */
hr {
color: var(--border);
border-top: 1px;
margin: 1rem auto;
}
mark {
padding: 2px 5px;
border-radius: 4px;
background: var(--marked);
}
main img,
main video {
max-width: 100%;
height: auto;
border-radius: 5px;
}
figure {
margin: 0;
}
figcaption {
font-size: 0.9rem;
color: var(--text-light);
text-align: center;
margin-bottom: 1rem;
}
blockquote {
margin: 2rem 0 2rem 2rem;
padding: 0.4rem 0.8rem;
border-left: 0.35rem solid var(--accent);
opacity: 0.8;
font-style: italic;
}
cite {
font-size: 0.9rem;
color: var(--text-light);
font-style: normal;
}
/* Use mono font for code like elements */
code,
pre,
pre span,
kbd,
samp {
font-size: 1.075rem;
font-family: var(--mono-font);
color: var(--code);
}
kbd {
color: var(--preformatted);
border: 1px solid var(--preformatted);
border-bottom: 3px solid var(--preformatted);
border-radius: 5px;
padding: 0.1rem;
}
pre {
padding: 1rem 1.4rem;
max-width: 100%;
overflow: auto;
overflow-x: auto;
color: var(--preformatted);
background: var(--accent-bg);
border: 1px solid var(--border);
border-radius: 5px;
}
/* Fix embedded code within pre */
pre code {
color: var(--preformatted);
background: none;
margin: 0;
padding: 0;
}