garbagespeak.com/server.go

973 lines
25 KiB
Go
Raw Normal View History

2023-07-25 19:48:30 +00:00
package main
import (
"bytes"
2023-07-25 20:32:04 +00:00
"context"
"crypto/tls"
2023-07-25 19:48:30 +00:00
"embed"
2023-07-29 16:36:24 +00:00
"errors"
2023-07-25 19:48:30 +00:00
"fmt"
2023-07-29 21:54:37 +00:00
"io/fs"
2023-07-25 19:48:30 +00:00
"log"
"net"
2023-07-25 19:48:30 +00:00
"net/http"
"net/smtp"
2023-07-25 19:48:30 +00:00
"os"
2023-07-30 23:06:41 +00:00
"strconv"
"strings"
2023-07-25 19:48:30 +00:00
"text/template"
"time"
"github.com/acaloiaro/garbage_speak/html_parser"
"github.com/acaloiaro/neoq"
"github.com/acaloiaro/neoq/backends/postgres"
"github.com/acaloiaro/neoq/handler"
"github.com/acaloiaro/neoq/jobs"
"github.com/acaloiaro/neoq/types"
2023-07-25 20:58:49 +00:00
"github.com/alexedwards/scs/pgxstore"
"github.com/alexedwards/scs/v2"
2023-07-25 20:32:04 +00:00
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/gofrs/uuid"
2023-07-25 19:48:30 +00:00
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
pgxuuid "github.com/jackc/pgx-gofrs-uuid"
"github.com/jackc/pgx/v5"
2023-07-25 20:32:04 +00:00
"github.com/jackc/pgx/v5/pgxpool"
2023-07-31 21:44:00 +00:00
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
gmhtml "github.com/yuin/goldmark/renderer/html"
2023-07-27 13:39:23 +00:00
"golang.org/x/crypto/bcrypt"
2023-07-25 19:48:30 +00:00
)
2023-07-27 13:39:23 +00:00
// Garbage represents 'garbage' records from the database
type Garbage struct {
2023-07-31 21:44:00 +00:00
ID uuid.UUID
OwnerID uuid.UUID
Username string
Title string
Content string // the raw, user-supplied content
RenderedContent *string // the content run through goldmark
Metadata map[string]any
Url string
CreatedAt time.Time
N int
2023-07-27 13:39:23 +00:00
}
// User represents 'user' records from the database
type User struct {
ID uuid.UUID
2023-07-27 13:39:23 +00:00
Username string
PasswordHash string
Email string
2023-07-27 13:39:23 +00:00
CreatedAt time.Time
UpdatedAt time.Time
}
// UserEmailVerification models pending user email verifications. If a User has a UserEmailVeritifcation, the account is
// pending verification and is not eligible to log in
type UserEmailVerification struct {
ID uuid.UUID
User *User
UserID uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
}
2023-07-25 19:48:30 +00:00
//go:embed migrations/*.sql
var migrationsFS embed.FS
2023-07-25 19:48:30 +00:00
2023-07-29 21:54:37 +00:00
//go:embed all:public
var publicFS embed.FS
2023-07-29 21:54:37 +00:00
2023-07-30 01:30:27 +00:00
//go:embed all:partials
var partialsFS embed.FS
2023-07-30 01:30:27 +00:00
2023-07-31 21:44:00 +00:00
var md goldmark.Markdown
2023-07-25 20:32:04 +00:00
var db *pgxpool.Pool
2023-07-25 20:58:49 +00:00
var sessions *scs.SessionManager
var sessionStore *pgxstore.PostgresStore
var NQ types.Backend
2023-07-31 14:55:20 +00:00
var pageSize = 25
2023-07-25 20:32:04 +00:00
2023-07-25 20:58:49 +00:00
// run migrations, acquire a database connection pool, and create the session store
2023-07-25 19:48:30 +00:00
func init() {
d, err := iofs.New(migrationsFS, "migrations")
2023-07-25 19:48:30 +00:00
if err != nil {
log.Fatal(err)
}
m, err := migrate.NewWithSourceInstance("iofs", d, os.Getenv("POSTGRES_URL"))
if err != nil {
log.Fatal(err)
}
err = m.Up()
if err != nil {
fmt.Fprintf(os.Stderr, "migrations: %v\n", err)
}
dbconfig, err := pgxpool.ParseConfig(os.Getenv("POSTGRES_URL"))
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to configure database: %v\n", err)
os.Exit(1)
}
dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
pgxuuid.Register(conn.TypeMap())
return nil
2023-07-25 19:48:30 +00:00
}
2023-07-25 20:32:04 +00:00
db, err = pgxpool.NewWithConfig(context.Background(), dbconfig)
2023-07-25 20:32:04 +00:00
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
2023-07-25 20:58:49 +00:00
sessions = scs.New()
sessionStore = pgxstore.New(db)
sessions.Cookie.Name = "session_id"
2023-07-28 16:13:13 +00:00
sessions.Cookie.HttpOnly = true
sessions.Cookie.Persist = true
sessions.Cookie.SameSite = http.SameSiteNoneMode
2023-07-28 16:13:13 +00:00
sessions.Cookie.Secure = true
2023-07-25 20:58:49 +00:00
sessions.Store = sessionStore
ctx := context.Background()
NQ, err = neoq.New(ctx,
neoq.WithBackend(postgres.Backend),
postgres.WithConnectionString(os.Getenv("POSTGRES_URL")),
postgres.WithTransactionTimeout(1000*60*5),
)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to initialize background worker: %v\n", err)
os.Exit(1)
}
err = NQ.Start(ctx, "welcome_email", handler.New(welcomeEmailHandler))
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to initialize welcome email handler: %v\n", err)
os.Exit(1)
}
2023-07-31 21:44:00 +00:00
md = goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
gmhtml.WithHardWraps(),
),
)
2023-07-25 19:48:30 +00:00
}
func main() {
2023-07-25 20:32:04 +00:00
defer db.Close()
2023-07-25 20:58:49 +00:00
defer sessionStore.StopCleanup()
defer NQ.Shutdown(context.Background())
2023-07-25 20:58:49 +00:00
serverRoot, _ := fs.Sub(publicFS, "public")
staticContentServer := http.FileServer(http.FS(serverRoot))
2023-07-29 21:54:37 +00:00
r := chi.NewRouter()
2023-07-28 16:13:13 +00:00
r.Use(sessions.LoadAndSave)
r.Use(middleware.Logger)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowCredentials: true,
AllowedOrigins: []string{"http://localhost:1313", fmt.Sprintf("https://%s", os.Getenv("SITE_DOMAIN"))},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
2023-08-01 15:44:04 +00:00
AllowedHeaders: []string{"Accept", "Cookie", "Authorization", "Content-Type", "X-CSRF-Token", "hx-target", "hx-current-url", "hx-request", "hx-trigger", "hx-trigger-name", "hx-boosted"},
2023-07-28 16:13:13 +00:00
ExposedHeaders: []string{"Link", "HX-Location", "Vary", "Access-Control-Allow-Origin"},
MaxAge: 300, // Maximum value not ignored by any of major browsers
}))
2023-07-25 19:48:30 +00:00
// Add any number of handlers for custom endpoints here
r.Route("/", func(r chi.Router) {
// any requests for which there are no defined chi routes are sent to the "file system"
// server, serving static Hugo content
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
staticContentServer.ServeHTTP(w, r)
})
r.Get("/nav/user_items", navUserItems)
r.Route("/users", func(users chi.Router) {
2023-07-29 21:54:37 +00:00
users.Post("/new_user_validation", newUserValidationHandler)
users.Get("/create", creatAccountPageHandler)
users.Post("/login", loginHandler)
users.Post("/create", createAccountHandler)
users.Get("/logout", logoutHandler)
users.Get("/email_verification/{uev_id}", emailVerification)
})
r.Route("/garbage", func(garbage chi.Router) {
2023-07-29 21:54:37 +00:00
garbage.Get("/list", listGarbageHandler)
garbage.Post("/new", createGarbageHandler)
garbage.Get("/{garbage_id}/edit", editGarbageHandler)
garbage.Put("/{garbage_id}", editGarbageUpdateHandler)
2023-07-30 13:40:50 +00:00
garbage.Get("/{garbage_id}", showGarbageHandler)
2023-07-30 23:06:41 +00:00
garbage.Put("/{garbage_id}/uplevel", addUplevelHandler)
garbage.Get("/{garbage_id}/uplevel", getUplevelHandler)
2023-07-29 21:54:37 +00:00
})
2023-07-29 16:36:24 +00:00
})
2023-07-25 19:48:30 +00:00
2023-07-29 23:29:57 +00:00
// this environment variable is present in production
// if the name of the port in job.nomad.hcl changes, this port
// name will need to change as well
port := os.Getenv("NOMAD_HOST_PORT_garbage_speak")
if port == "" {
port = "1314"
}
addr := fmt.Sprintf("%s:%s", "0.0.0.0", port)
fmt.Println("Starting API server on", addr)
if err := http.ListenAndServe(addr, r); err != nil {
2023-07-25 19:48:30 +00:00
log.Fatal(err)
}
}
2023-07-30 23:06:41 +00:00
func getUplevelHandler(w http.ResponseWriter, r *http.Request) {
garbageID := chi.URLParam(r, "garbage_id")
var uplevel int
db.QueryRow(r.Context(), "SELECT COUNT(*) FROM uplevels WHERE garbage_id = $1", garbageID).Scan(&uplevel)
w.Write([]byte(strconv.Itoa(uplevel)))
w.WriteHeader(http.StatusOK)
}
func addUplevelHandler(w http.ResponseWriter, r *http.Request) {
garbageID := chi.URLParam(r, "garbage_id")
userID := sessions.GetString(r.Context(), "userID")
if userID == "" {
ise(errors.New("not logged in"), w)
return
}
ctx := context.Background()
db.QueryRow(ctx,
"INSERT INTO uplevels(garbage_id, user_id) VALUES ($1, $2)",
garbageID,
userID)
2023-07-30 23:33:11 +00:00
garbageUUID, _ := uuid.FromString(garbageID)
garbage := Garbage{ID: garbageUUID}
tmpl := template.Must(template.ParseFS(partialsFS, "partials/garbage/uplevel_button.tmpl"))
err := tmpl.ExecuteTemplate(w, "uplevel_button.tmpl", map[string]any{
"Garbage": garbage,
"ApiBaseUrl": apiURL(),
"UserID": userID,
})
if err != nil {
ise(err, w)
return
}
w.WriteHeader(http.StatusOK)
2023-07-30 23:06:41 +00:00
}
2023-07-29 19:26:45 +00:00
func editGarbageUpdateHandler(w http.ResponseWriter, r *http.Request) {
garbageID := chi.URLParam(r, "garbage_id")
userID := sessions.GetString(r.Context(), "userID")
if userID == "" {
ise(errors.New("not logged in"), w)
return
}
if err := r.ParseForm(); err != nil {
ise(err, w)
return
}
url := r.PostForm.Get("url")
title := r.PostForm.Get("title")
2023-07-31 21:44:00 +00:00
content := r.PostForm.Get("garbage")
2023-07-29 19:26:45 +00:00
// TODO this is an arbitrary length that will likely need to change
2023-07-31 21:44:00 +00:00
if len(content) == 10 {
2023-07-29 19:26:45 +00:00
w.WriteHeader(400)
return
}
2023-07-31 21:44:00 +00:00
renderedContent := mdToHtml(content)
2023-07-29 19:26:45 +00:00
metadata := map[string]any{}
tags := r.Form["tags"]
if len(tags) > 0 {
metadata["tags"] = tags
}
ctx := context.Background()
_, err := db.Exec(ctx,
2023-07-31 21:44:00 +00:00
"UPDATE garbages SET (title, content, rendered_content, url, metadata) = ($1, $2, $3, $4, $5) WHERE id = $6",
2023-07-29 19:26:45 +00:00
title,
2023-07-31 21:44:00 +00:00
content,
renderedContent,
2023-07-29 19:26:45 +00:00
url,
metadata,
garbageID)
if err != nil {
2023-07-29 21:54:37 +00:00
ise(err, w)
2023-07-29 19:26:45 +00:00
return
}
w.Header().Add("hx-location", appURL())
2023-07-29 19:26:45 +00:00
}
func editGarbageHandler(w http.ResponseWriter, r *http.Request) {
garbageID := chi.URLParam(r, "garbage_id")
userID := sessions.GetString(r.Context(), "userID")
garbage := Garbage{}
ctx := context.Background()
err := pgxscan.Get(
ctx,
db,
&garbage,
2023-07-31 21:44:00 +00:00
`SELECT id, owner_id, title, content, rendered_content, metadata, url FROM garbages WHERE id = $1 AND owner_id = $2`, garbageID, userID)
2023-07-29 19:26:45 +00:00
if err != nil {
2023-07-29 20:24:19 +00:00
w.WriteHeader(http.StatusUnauthorized)
return
}
if userID == "" || userID != garbage.OwnerID.String() {
w.WriteHeader(http.StatusUnauthorized)
2023-07-29 19:26:45 +00:00
return
}
availableTags := []string{
"Nouned verb",
"Verbed noun",
"Nouned adjective",
"Novel garbage",
2023-07-30 04:17:08 +00:00
"Standard-issue garbage",
2023-07-29 19:26:45 +00:00
}
selectedTags := map[string]bool{}
if tags, ok := garbage.Metadata["tags"].([]interface{}); ok {
for _, tag := range tags {
selectedTags[tag.(string)] = true
}
}
tmplVars := map[string]any{
"ApiBaseUrl": apiURL(),
"Garbage": garbage,
"SelectedTags": selectedTags,
"AvailableTags": availableTags,
}
tmpl := template.Must(template.ParseFS(partialsFS, "partials/garbage/edit.html"))
2023-07-30 01:30:27 +00:00
err = tmpl.ExecuteTemplate(w, "edit.html", tmplVars)
2023-07-29 19:26:45 +00:00
if err != nil {
ise(err, w)
return
}
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
}
2023-07-29 14:06:30 +00:00
// newUserValidationHandler checks whether a username is available during account creation
func newUserValidationHandler(w http.ResponseWriter, r *http.Request) {
tmplVars := map[string]any{"ApiBaseUrl": apiURL()}
tmpl := template.Must(template.ParseFS(partialsFS, "partials/users/new_user_validation.html"))
2023-07-30 01:30:27 +00:00
2023-07-29 14:06:30 +00:00
errCnt := 0
if err := r.ParseForm(); err != nil {
ise(err, w)
return
}
username := r.PostForm.Get("username")
email := r.PostForm.Get("email")
password := r.PostForm.Get("password")
passwordConfirmation := r.PostForm.Get("password_confirmation")
tmplVars["Username"] = username
tmplVars["Email"] = email
tmplVars["Password"] = password
tmplVars["PasswordConfirmation"] = passwordConfirmation
if len(username) == 0 {
tmplVars["UsernameError"] = "Please choose a username"
errCnt += 1
} else if len(username) > 0 {
var userID string
db.QueryRow(r.Context(), "SELECT id FROM users WHERE username = $1", username).Scan(&userID)
if userID != "" {
tmplVars["UsernameError"] = fmt.Sprintf("Username '%s' is unavailable. Choose a different username.", username)
errCnt += 1
}
}
if (len(email) > 0 && len(email) < 4) || (len(email) >= 4 && !strings.Contains(email, "@")) {
tmplVars["EmailError"] = "Please enter a valid email address."
errCnt += 1
}
if len(password) > 0 && len(password) < 8 {
tmplVars["PasswordError"] = "Please choose a password greater than 8 characters"
errCnt += 1
}
if len(password) > 0 && len(passwordConfirmation) > 0 && password != passwordConfirmation {
tmplVars["PasswordConfirmationError"] = "Passwords do not match"
errCnt += 1
}
tmplVars["ErrorCount"] = errCnt
2023-07-30 01:30:27 +00:00
err := tmpl.ExecuteTemplate(w, "new_user_validation.html", tmplVars)
2023-07-29 14:06:30 +00:00
if err != nil {
ise(err, w)
return
}
2023-07-28 13:55:36 +00:00
2023-07-29 14:06:30 +00:00
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
}
// loginHandler handles login requests and performs validation
2023-07-28 13:55:36 +00:00
func loginHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
ise(err, w)
return
}
username := r.PostForm.Get("username")
password := r.PostForm.Get("password")
var userID string
var storedPasswordHash string
db.QueryRow(r.Context(), "SELECT id,password FROM users WHERE username = $1", username).Scan(&userID, &storedPasswordHash)
var err error
2023-07-28 13:55:36 +00:00
if err = bcrypt.CompareHashAndPassword([]byte(storedPasswordHash), []byte(password)); err == nil {
err = sessions.RenewToken(r.Context())
if err != nil {
ise(err, w)
return
}
sessions.Put(r.Context(), "userID", userID)
w.Header().Add("hx-location", appURL())
2023-07-28 13:55:36 +00:00
return
}
tmpl := template.Must(template.ParseFS(partialsFS, "partials/users/login_validation.html"))
err = tmpl.ExecuteTemplate(w, "login_validation.html", map[string]any{
"ApiBaseURL": apiURL(),
"LoginError": "Incorrect username or password",
"Username": username,
"Password": password,
})
if err != nil {
ise(err, w)
return
}
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(200)
2023-07-28 13:55:36 +00:00
}
// logoutHandler handles users logout requests
func logoutHandler(w http.ResponseWriter, r *http.Request) {
2023-07-29 19:26:45 +00:00
sessions.Destroy(r.Context())
2023-08-01 15:44:04 +00:00
w.Header().Add("hx-location", appURL())
w.WriteHeader(http.StatusOK)
2023-07-28 13:55:36 +00:00
}
2023-07-28 01:54:44 +00:00
// navUserItems returns a nav items depending on whether the user is logged in
func navUserItems(w http.ResponseWriter, r *http.Request) {
var tmpl *template.Template
2023-07-30 01:30:27 +00:00
var err error
tmpl = template.Must(template.ParseFS(partialsFS, "partials/nav/*.html"))
2023-07-29 21:54:37 +00:00
if isLoggedIn(r) {
2023-07-30 01:30:27 +00:00
err = tmpl.ExecuteTemplate(w, "user_nav_items.html", map[string]any{"ApiURL": apiURL()})
2023-07-29 21:54:37 +00:00
} else {
2023-07-30 01:30:27 +00:00
err = tmpl.ExecuteTemplate(w, "non_user_nav_items.html", map[string]any{"ApiURL": apiURL()})
2023-07-28 01:54:44 +00:00
}
if err != nil {
ise(err, w)
return
}
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
}
// creatAccountPageHandler serves the new account form
func creatAccountPageHandler(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.ParseFS(partialsFS, "partials/users/*"))
2023-07-30 01:30:27 +00:00
err := tmpl.ExecuteTemplate(w, "create.html", map[string]any{})
2023-07-28 01:14:38 +00:00
if err != nil {
ise(err, w)
return
}
w.Header().Add("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
}
// emailVerification takes a UserEmailVerification ID, and if it exists, verifies the associated User account by
// deleting the UEV record (user login's check if a UEV exists for a User before permitting login)
func emailVerification(w http.ResponseWriter, r *http.Request) {
// user email verification id
uevID := chi.URLParam(r, "uev_id")
tx, err := db.Begin(r.Context())
if err != nil {
ise(err, w)
return
}
// Rollback is safe to call even if the tx is already closed, so if
// the tx commits successfully, this is a no-op
defer tx.Rollback(r.Context())
var userID string
err = tx.QueryRow(r.Context(), "DELETE FROM user_email_verifications WHERE id = $1 RETURNING user_id", uevID).Scan(&userID)
if err != nil {
log.Fatalf("can't verify email address: %v", err)
}
err = sessions.RenewToken(r.Context())
if err != nil {
ise(err, w)
return
}
// create the user's session
sessions.Put(r.Context(), "userID", userID)
2023-07-28 13:55:36 +00:00
tx.Commit(r.Context())
http.Redirect(w, r, appURL(), http.StatusFound)
}
2023-07-31 21:44:00 +00:00
func mdToHtml(markdown string) (html string) {
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {
panic(err)
}
html = buf.String()
return
}
2023-07-29 16:36:24 +00:00
func createGarbageHandler(w http.ResponseWriter, r *http.Request) {
if !isLoggedIn(r) {
w.Header().Add("hx-location", fmt.Sprintf("%s/users/login", appURL()))
return
}
2023-07-29 16:36:24 +00:00
userID := sessions.GetString(r.Context(), "userID")
if userID == "" {
ise(errors.New("not logged in"), w)
return
}
if err := r.ParseForm(); err != nil {
ise(err, w)
return
}
title := r.PostForm.Get("title")
2023-07-31 21:44:00 +00:00
content := r.PostForm.Get("garbage")
2023-07-29 16:36:24 +00:00
// TODO this is an arbitrary length that will likely need to change
2023-07-31 21:44:00 +00:00
if len(content) == 10 {
2023-07-29 16:36:24 +00:00
w.WriteHeader(400)
return
}
2023-07-31 21:44:00 +00:00
renderedContent := mdToHtml(content)
2023-07-29 16:36:24 +00:00
url := r.PostForm.Get("url")
2023-07-29 19:26:45 +00:00
metadata := map[string]any{}
2023-07-29 16:36:24 +00:00
tags := r.Form["tags"]
2023-07-29 19:26:45 +00:00
if len(tags) > 0 {
metadata["tags"] = tags
2023-07-29 16:36:24 +00:00
}
ctx := context.Background()
db.QueryRow(ctx,
2023-07-31 21:44:00 +00:00
"INSERT INTO garbages(title, content, rendered_content, url, metadata, owner_id) VALUES ($1, $2, $3, $4, $5, $6)",
2023-07-29 16:36:24 +00:00
title,
2023-07-31 21:44:00 +00:00
content,
renderedContent,
2023-07-29 16:36:24 +00:00
url,
metadata,
userID)
w.Header().Add("hx-location", appURL())
2023-07-29 16:36:24 +00:00
}
// createAccountHandler creates new accounts
func createAccountHandler(w http.ResponseWriter, r *http.Request) {
2023-07-27 13:39:23 +00:00
if err := r.ParseForm(); err != nil {
ise(err, w)
return
}
username := r.PostForm.Get("username")
if len(username) == 0 {
w.WriteHeader(400)
return
}
password := r.PostForm.Get("password")
if len(password) < 8 {
w.WriteHeader(400)
return
}
email := r.PostForm.Get("email")
if !strings.Contains(email, "@") {
w.WriteHeader(400)
return
}
2023-07-27 13:39:23 +00:00
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
ise(err, w)
2023-07-29 21:54:37 +00:00
return
2023-07-27 13:39:23 +00:00
}
ctx := context.Background()
tx, err := db.Begin(ctx)
if err != nil {
ise(err, w)
return
2023-07-27 13:39:23 +00:00
}
// Rollback is safe to call even if the tx is already closed, so if
// the tx commits successfully, this is a no-op
defer tx.Rollback(ctx)
var userID string
err = tx.QueryRow(ctx, "insert into users(username, password, email) values ($1, $2, $3) returning id", username, passwordHash, email).Scan(&userID)
if err != nil {
ise(err, w)
return
}
var uevID string
err = tx.QueryRow(ctx, "insert into user_email_verifications(user_id) values ($1) returning id", userID).Scan(&uevID)
2023-07-27 13:39:23 +00:00
if err != nil {
ise(err, w)
return
2023-07-27 13:39:23 +00:00
}
err = tx.Commit(ctx)
if err != nil {
ise(err, w)
return
2023-07-27 13:39:23 +00:00
}
_, err = NQ.Enqueue(ctx, &jobs.Job{
Queue: "welcome_email",
Payload: map[string]interface{}{
"recipient": email,
"verification_url": fmt.Sprintf("%s/users/email_verification/%s", apiURL(), uevID),
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "unable to queue email veritifcation: %v", err)
return
}
tmpl := template.Must(template.ParseFS(partialsFS, "partials/users/*"))
2023-07-30 01:30:27 +00:00
err = tmpl.ExecuteTemplate(w, "created.html", map[string]any{"Email": email})
2023-07-28 01:14:38 +00:00
if err != nil {
ise(err, w)
return
}
w.WriteHeader(http.StatusOK)
2023-07-27 13:39:23 +00:00
}
2023-07-31 00:11:43 +00:00
// argsfn is a template function to pass arbitrary template variables into sub-templates
func argsfn(kvs ...interface{}) (map[string]interface{}, error) {
if len(kvs)%2 != 0 {
return nil, errors.New("args requires even number of arguments")
}
m := make(map[string]interface{})
for i := 0; i < len(kvs); i += 2 {
s, ok := kvs[i].(string)
if !ok {
return nil, errors.New("even args to args must be strings")
}
m[s] = kvs[i+1]
2023-07-30 21:48:56 +00:00
}
2023-07-31 00:11:43 +00:00
return m, nil
2023-07-30 21:48:56 +00:00
}
2023-07-31 14:55:20 +00:00
// pagedQuery returns a paged query for the given query
func pagedQuery(r *http.Request, query string) (pagedQuery string, firstItem *int) {
firstItemStr := r.URL.Query().Get("first_item")
var err error
var fi int
fi, err = strconv.Atoi(firstItemStr)
if err != nil {
pagedQuery = query + " ORDER BY n DESC LIMIT $1"
return
}
firstItem = &fi
// newer items have a larger n, since n monotonically increases
// hence we filter where n < our first item's n
pagedQuery = query + " WHERE n < $2"
pagedQuery = pagedQuery + " ORDER BY n DESC LIMIT $1"
return
}
// listGarbageHandler returns the latest garbage
func listGarbageHandler(w http.ResponseWriter, r *http.Request) {
2023-07-29 20:24:19 +00:00
userID := sessions.GetString(r.Context(), "userID")
2023-07-31 14:55:20 +00:00
query := `SELECT
2023-07-31 21:44:00 +00:00
garbages.id, n, owner_id, username, title, rendered_content, metadata, url, garbages.created_at
2023-07-31 14:55:20 +00:00
FROM garbages
JOIN users ON garbages.owner_id = users.id`
pagedQuery, firstItem := pagedQuery(r, query)
2023-07-25 20:32:04 +00:00
ctx := context.Background()
2023-07-30 13:40:50 +00:00
garbage := []*Garbage{}
2023-07-31 14:55:20 +00:00
var err error
if firstItem != nil {
err = pgxscan.Select(ctx, db, &garbage, pagedQuery, pageSize, firstItem)
} else {
err = pgxscan.Select(ctx, db, &garbage, pagedQuery, pageSize)
}
2023-07-25 20:32:04 +00:00
if err != nil {
2023-07-29 21:54:37 +00:00
ise(err, w)
2023-07-25 20:32:04 +00:00
return
2023-07-25 19:48:30 +00:00
}
2023-07-28 01:14:38 +00:00
2023-07-30 21:48:56 +00:00
tmpl := template.Must(
template.New("list.html").
2023-07-31 00:11:43 +00:00
Funcs(template.FuncMap{"argsfn": argsfn}).
2023-07-30 23:33:11 +00:00
ParseFS(partialsFS, "partials/garbage/list.html", "partials/garbage/*.tmpl"))
2023-07-30 21:48:56 +00:00
2023-07-31 14:55:20 +00:00
// pagination
var lastItem *int
il := len(garbage) - 1
if il >= 0 {
lastItem = &(garbage[il].N)
}
var buff = bytes.NewBufferString("")
err = tmpl.ExecuteTemplate(buff, "list.html", map[string]any{
2023-07-30 13:40:50 +00:00
"Posts": garbage,
"ApiBaseUrl": apiURL(),
"LoggedIn": isLoggedIn(r),
"UserID": userID,
2023-07-31 14:55:20 +00:00
"LastItem": lastItem,
2023-07-30 13:40:50 +00:00
})
if err != nil {
ise(err, w)
return
}
2023-07-31 14:55:20 +00:00
if isPartialRequest(r) {
w.Write(buff.Bytes())
} else {
indexFile, _ := publicFS.Open("public/index.html")
content := html_parser.ParseAndSplice(indexFile, "content", buff.String())
w.Write([]byte(content))
}
2023-07-30 13:40:50 +00:00
w.WriteHeader(http.StatusOK)
}
2023-07-31 14:55:20 +00:00
func isPartialRequest(r *http.Request) bool {
return r.Header.Get("hx-request") != ""
}
2023-07-30 13:40:50 +00:00
// showGarbageHandler returns the latest garbage
func showGarbageHandler(w http.ResponseWriter, r *http.Request) {
garbageID := chi.URLParam(r, "garbage_id")
userID := sessions.GetString(r.Context(), "userID")
ctx := context.Background()
garbage := Garbage{}
err := pgxscan.Get(
ctx,
db,
&garbage,
2023-07-31 21:44:00 +00:00
`SELECT garbages.id, owner_id, username, title, rendered_content, metadata, url, garbages.created_at
2023-07-30 13:40:50 +00:00
FROM garbages
JOIN users ON garbages.owner_id = users.id
2023-07-31 14:55:20 +00:00
WHERE garbages.id = $1`, garbageID)
2023-07-30 13:40:50 +00:00
if err != nil {
ise(err, w)
return
}
var buff = bytes.NewBufferString("")
2023-07-31 00:11:43 +00:00
tmpl := template.Must(
template.New("list.html").
Funcs(template.FuncMap{"argsfn": argsfn}).
ParseFS(partialsFS, "partials/garbage/show.tmpl", "partials/garbage/*.tmpl"))
2023-07-30 21:48:56 +00:00
err = tmpl.ExecuteTemplate(buff, "show.tmpl", map[string]any{
2023-07-30 13:40:50 +00:00
"Garbage": garbage,
2023-07-29 19:26:45 +00:00
"ApiBaseUrl": apiURL(),
"LoggedIn": isLoggedIn(r),
2023-07-29 20:24:19 +00:00
"UserID": userID,
2023-07-29 19:26:45 +00:00
})
2023-07-25 19:48:30 +00:00
if err != nil {
ise(err, w)
return
}
2023-07-31 14:55:20 +00:00
if isPartialRequest(r) {
w.Write(buff.Bytes())
} else {
indexFile, _ := publicFS.Open("public/index.html")
content := html_parser.ParseAndSplice(indexFile, "content", buff.String())
w.Write([]byte(content))
}
w.WriteHeader(http.StatusOK)
2023-07-25 20:32:04 +00:00
}
// sendWelcomeEmail sends an email to recipient containing a special URL that only that can know, for the purpose of
// email address verification
func sendWelcomeEmail(recipient, verificationURL, siteName string) error {
2023-07-30 02:51:31 +00:00
smtpHost := os.Getenv("SMTP_HOST")
2023-07-30 03:28:06 +00:00
from := fmt.Sprintf("noreply@%s", os.Getenv("SITE_DOMAIN"))
2023-07-30 03:12:44 +00:00
msg := []byte(fmt.Sprintf("To: %s\r\n", recipient) +
2023-07-30 03:12:44 +00:00
fmt.Sprintf("From: %s", from) + "\r\n" +
fmt.Sprintf("Subject: Welcome to %s!\r\n", siteName) + "\r\n" +
fmt.Sprintf("Verify your email address by visiting: %s\r\n", verificationURL))
2023-07-30 02:51:31 +00:00
host, _, _ := net.SplitHostPort(smtpHost)
auth := smtp.PlainAuth("", os.Getenv("SMTP_USERNAME"), os.Getenv("SMTP_PASSWORD"), host)
// Here is the key, you need to call tls.Dial instead of smtp.Dial
// for smtp servers running on 465 that require an ssl connection
// from the very beginning (no starttls)
2023-07-30 02:51:31 +00:00
c, err := smtp.Dial(smtpHost)
if err != nil {
log.Panic(err)
}
2023-07-30 02:51:31 +00:00
// TLS config
tlsconfig := &tls.Config{
InsecureSkipVerify: true,
ServerName: host,
}
2023-07-30 02:51:31 +00:00
c.StartTLS(tlsconfig)
// Auth
if err = c.Auth(auth); err != nil {
log.Panic(err)
}
// From
2023-07-30 03:12:44 +00:00
if err = c.Mail(from); err != nil {
log.Panic(err)
}
//Recipient
if err = c.Rcpt(recipient); err != nil {
log.Panic(err)
}
// Data
w, err := c.Data()
if err != nil {
log.Panic(err)
}
_, err = w.Write(msg)
if err != nil {
log.Panic(err)
}
err = w.Close()
if err != nil {
log.Panic(err)
}
c.Quit()
return nil
}
// welcomeEmailHandler sends a welcome email to new users
func welcomeEmailHandler(ctx context.Context) (err error) {
var j *jobs.Job
j, err = jobs.FromContext(ctx)
if err != nil {
log.Println("unable to process welcome email:", err)
return
}
recipient := j.Payload["recipient"].(string)
verificationURL := j.Payload["verification_url"].(string)
err = sendWelcomeEmail(recipient, verificationURL, "Garbage Speak")
return
}
// env returns the server's environment name, e.g. "development"
func env() string {
e := os.Getenv("GO_ENV")
if e != "" {
return e
}
return "development"
}
// appURL returns the site's base URL, e.g. http://localhost:1313 in development, https://<SITE_DOMAIN> in production
func appURL() string {
2023-07-29 21:54:37 +00:00
if env() == "development" {
return fmt.Sprintf("http://%s", os.Getenv("SITE_HOST"))
}
2023-07-29 21:54:37 +00:00
addr := os.Getenv("SITE_DOMAIN")
if addr == "" {
log.Fatalf("SITE_DOMAIN is not set")
}
2023-07-29 21:54:37 +00:00
return fmt.Sprintf("https://%s", addr)
}
// apiURL returns the server's base URL, e.g. http://localhost:1314 in development, https://<API_HOST> in production
func apiURL() string {
2023-07-29 21:54:37 +00:00
if env() == "development" {
return fmt.Sprintf("http://%s", os.Getenv("API_HOST"))
2023-07-29 21:54:37 +00:00
}
2023-07-29 21:54:37 +00:00
addr := os.Getenv("SITE_DOMAIN")
if addr == "" {
log.Fatalf("SITE_DOMAIN is not set")
}
2023-07-30 19:26:36 +00:00
return fmt.Sprintf("https://%s", addr)
}
func ise(err error, w http.ResponseWriter) {
fmt.Fprintf(w, "error: %v", err)
w.WriteHeader(http.StatusInternalServerError)
}
2023-07-29 19:26:45 +00:00
func isLoggedIn(r *http.Request) bool {
var _, err = r.Cookie("session_id")
return err == nil
}