Ronbun/cmd/web/handlers.go
2026-07-18 00:56:15 +02:00

887 lines
24 KiB
Go

package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/Thomasorus/Ronbun-CMS/cmd/web/kaku"
"github.com/Thomasorus/Ronbun-CMS/internal/models"
"github.com/Thomasorus/Ronbun-CMS/internal/validator"
"github.com/gorilla/feeds"
"github.com/gosimple/slug"
)
// Handlers are like MVC controllers. They execute application logic and
// for writing http responses to headers and bodies.
// To inject dependencies into handlers (logger, cache templating), the handlers are
//defined as methods against the application struct created in main.go.
// Map html to struct values with the `form:"item"` from the validator package
type pageCreateForm struct {
Name string `form:"name"`
Summary string `form:"summary"`
Body string `form:"body"`
Host int `form:"host"`
Category string `form:"category"`
Private int `form:"private"`
Feed bool `form:"feed"`
Menu bool `form:"menu"`
Created string `form:"created"`
validator.Validator `form:"-"`
}
type pageEditForm struct {
Name string `form:"name"`
Summary string `form:"summary"`
Body string `form:"body"`
Host int `form:"host"`
Category string `form:"category"`
Private int `form:"private"`
Feed bool `form:"feed"`
Menu bool `form:"menu"`
Id int `form:"id"`
Slug string `form:"slug"`
Created string `form:"created"`
validator.Validator `form:"-"`
}
type userSignupForm struct {
Name string `form:"name"`
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
type userLoginInForm struct {
Email string `form:"email"`
Password string `form:"password"`
validator.Validator `form:"-"`
}
type searchForm struct {
Search string `form:"search"`
validator.Validator `form:"-"`
}
type accountPasswordUpdateForm struct {
CurrentPassword string `form:"currentPassword"`
NewPassword string `form:"newPassword"`
NewPasswordConfirmation string `form:"newPasswordConfirmation"`
validator.Validator `form:"-"`
}
// Serves the /og route
func (app *application) getEmbed(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.NotFound(w, r)
return
}
if data, ok := app.ogCache.Load(slug); ok {
w.Header().Set("Content-Type", "image/jpeg")
w.Write(data.([]byte))
return
}
var page models.Page
authenticated := app.isAuthenticated(r)
page, err := app.pages.Get(authenticated, slug)
if err != nil || page.Private == 2 {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
if slug == "hire" {
page.Name = "Let's work together!"
}
if slug == "home" {
page.Name = "Welcome!"
}
buf, err := generateOpenGraphImage(page)
if err != nil {
app.serverError(w, r, err)
}
app.ogCache.Store(page.Slug, buf.Bytes())
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(buf.Bytes())
}
// Serves the /home route
func (app *application) home(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Page.Name = "Home"
data.Page.Summary = "The personal website of Thomasorus"
data.Page.Category = "none"
data.Page.Slug = "/"
app.render(w, r, http.StatusOK, "home.tmpl", data)
}
// Serves the /search route
func (app *application) search(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Page.Name = "Search"
data.Page.Summary = "Search for anything on this website"
data.Page.Category = "none"
data.Page.Slug = "search"
data.Form = searchForm{}
app.render(w, r, http.StatusOK, "search.tmpl", data)
}
// Serve the search POST route
func (app *application) searchPost(w http.ResponseWriter, r *http.Request) {
var form searchForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.Search), "search", "This field cannot be blank")
form.CheckField(validator.MinChars(form.Search, 3), "search", "This field must be at least 3 characters long")
if !form.Valid() {
data := app.newTemplateData(r)
data.Page.Name = "Search"
data.Page.Summary = "Search for anything on this website"
data.Page.Category = "none"
data.Page.Slug = "search"
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "search.tmpl", data)
return
}
data := app.newTemplateData(r)
data.Form = form
authenticated := app.isAuthenticated(r)
pages, err := app.pages.SearchInPages(authenticated, form.Search)
if err != nil {
app.serverError(w, r, err)
return
}
data.Page.Name = "Search"
data.Page.Summary = "Search for anything on this website"
data.Page.Category = "none"
data.Page.Slug = "search"
data.Page.ChildrenMenu = pages
app.render(w, r, http.StatusOK, "search.tmpl", data)
}
// Srves the /feed route
func (app *application) feed(w http.ResponseWriter, r *http.Request) {
now := time.Now()
feed := &feeds.Feed{
Title: "Thomasorus",
Id: "https://thomasorus.com/feed",
Link: &feeds.Link{Href: `https://thomasorus.com/feed`, Type: "text/html", Rel: "self"},
Description: "The personal website of Thomasorus",
Author: &feeds.Author{Name: "Thomasorus", Email: "contact@thomasorus.com"},
Created: now,
}
pages, err := app.pages.GetRssFeedPages()
if err != nil {
app.serverError(w, r, err)
return
}
var feedItems []*feeds.Item
for i := 0; i < len(pages); i++ {
item := pages[i]
feedItems = append(feedItems,
&feeds.Item{
Id: `https://thomasorus.com/` + item.Slug,
Title: item.Name,
Link: &feeds.Link{Href: `https://thomasorus.com/` + item.Slug, Type: "text/html", Rel: "self"},
Created: item.Created,
Updated: item.Updated,
Content: string(kaku.Parse(item.Body).Render()),
Description: item.Summary,
})
}
feed.Items = feedItems
atom, err := feed.ToAtom()
if err != nil {
app.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(atom))
}
// Serves the /hire-me route
func (app *application) hireme(w http.ResponseWriter, r *http.Request) {
lang := strings.Split(r.Header.Get("Accept-Language"), ",")[0]
data := app.newTemplateData(r)
data.Page.Category = "hire"
data.Page.Slug = "hire"
if lang == "fr" {
data.Page.Name = "Travaillons ensemble"
} else {
data.Page.Name = "For Hire"
}
breadCrumb, err := app.pages.BuildBreadCrumb(data.Page.Host)
if err != nil {
app.serverError(w, r, err)
return
}
data.Page.BreadCrumb = breadCrumb
if lang == "fr" {
app.render(w, r, http.StatusOK, "recrutement.tmpl", data)
} else {
app.render(w, r, http.StatusOK, "hireme.tmpl", data)
}
}
func (app *application) sitemap(w http.ResponseWriter, r *http.Request) {
// If page is sitemap, build it
data := app.newTemplateData(r)
data.Page.Name = "Sitemap"
data.Page.Category = "none"
data.Page.Slug = "sitemap"
authenticated := app.isAuthenticated(r)
pages, err := app.pages.IdNameSlugHost(authenticated)
if err != nil {
app.serverError(w, r, err)
return
}
var pagePtrs []*models.Page
for i := range pages {
pagePtrs = append(pagePtrs, &pages[i])
}
data.Page.Html = `<h1>Sitemap</h1>` + app.BuildTree(pagePtrs)
breadCrumb, err := app.pages.BuildBreadCrumb(data.Page.Host)
if err != nil {
app.serverError(w, r, err)
return
}
data.Page.BreadCrumb = breadCrumb
app.render(w, r, http.StatusOK, "view.tmpl", data)
}
// Serves the /{slug}/ route
func (app *application) pageView(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.NotFound(w, r)
return
}
// Edge cases: redirect old .html pages to clean slugs
if strings.Contains(slug, ".html") {
slug = strings.Replace(slug, ".html", "", 1)
if slug == "home" {
http.Redirect(w, r, "/", http.StatusPermanentRedirect)
return
} else {
http.Redirect(w, r, "/"+slug, http.StatusPermanentRedirect)
return
}
}
// Edge case: redirect feed.xml pages to /feed
if strings.Contains(slug, ".html") || strings.Contains(slug, ".xml") {
slug = strings.Replace(slug, ".xml", "", 1)
if slug == "feed" {
http.Redirect(w, r, "/feed", http.StatusPermanentRedirect)
}
}
authenticated := app.isAuthenticated(r)
page, err := app.pages.Get(authenticated, slug)
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
if page.Menu {
page.ChildrenMenu, err = app.pages.GetChildrenMenu(authenticated, page.ID)
if err != nil {
app.serverError(w, r, err)
return
}
if page.Name == "Journal" {
sort.Slice(page.ChildrenMenu, func(i, j int) bool {
return page.ChildrenMenu[i].Created.After(page.ChildrenMenu[j].Created)
})
}
}
page.Html = kaku.Parse(page.Body).Render()
data := app.newTemplateData(r)
data.Page = page
breadCrumb, err := app.pages.BuildBreadCrumb(data.Page.Host)
if err != nil {
app.serverError(w, r, err)
return
}
data.Page.BreadCrumb = breadCrumb
app.render(w, r, http.StatusOK, "view.tmpl", data)
}
// Serve the /page/create GET route
func (app *application) pageCreate(w http.ResponseWriter, r *http.Request) {
pages, err := app.pages.NamesAndSlugs()
if err != nil {
app.serverError(w, r, err)
return
}
data := app.newTemplateData(r)
data.Form = pageCreateForm{}
data.Pages = pages
data.Page.Name = "New page"
app.render(w, r, http.StatusOK, "create.tmpl", data)
}
// Serve the page/create POST route
func (app *application) pageCreatePost(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
// Decode the form
var form pageCreateForm
err = app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
// Check if another page already uses this slug
slug := slug.Make(r.PostForm.Get("name"))
slugIsFree, err := app.pages.SlugIsFree(slug, 0)
if err != nil {
app.serverError(w, r, err)
return
}
form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
form.CheckField(validator.SlugIsFree(slugIsFree), "name", "A page already exists with this title")
form.CheckField(validator.NotBlank(form.Body), "body", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.Summary), "summary", "This field cannot be blank")
// form.CheckField(validator.NotBlankInt(form.Host), "host", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.Category), "category", "This field cannot be blank")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
pages, err := app.pages.NamesAndSlugs()
if err != nil {
app.serverError(w, r, err)
return
}
data.Pages = pages
app.render(w, r, http.StatusUnprocessableEntity, "create.tmpl", data)
return
}
now := time.Now()
created, err := time.Parse("2006-01-02T15:04", form.Created)
if err != nil {
app.serverError(w, r, err)
return
}
err = app.pages.Insert(form.Name, slug, form.Host, form.Category, form.Summary, form.Body, created, now, form.Private, form.Menu, form.Feed)
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "flash", "Page successfully created!")
http.Redirect(w, r, "/"+slug, http.StatusSeeOther)
}
// Serve the page/edit GET route
func (app *application) pageEdit(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
http.NotFound(w, r)
return
}
pages, err := app.pages.NamesAndSlugs()
if err != nil {
app.serverError(w, r, err)
return
}
authenticated := app.isAuthenticated(r)
page, err := app.pages.Get(authenticated, slug)
if err != nil {
if errors.Is(err, models.ErrNoRecord) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
var form pageEditForm
form.Name = page.Name
form.Summary = page.Summary
form.Body = page.Body
form.Created = page.Created.Format("2006-01-02T15:04")
form.Host = page.Host
form.Category = page.Category
form.Private = page.Private
form.Feed = page.Feed
form.Menu = page.Menu
form.Id = page.ID
form.Slug = slug
data := app.newTemplateData(r)
data.Form = form
data.Pages = pages
data.Page.Name = "Edit page"
app.render(w, r, http.StatusOK, "edit.tmpl", data)
}
func (app *application) PageEditPost(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
var form pageEditForm
err = app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
slug := slug.Make(r.PostForm.Get("name"))
slugIsFree, err := app.pages.SlugIsFree(slug, form.Id)
if err != nil {
app.serverError(w, r, err)
return
}
form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
form.CheckField(validator.SlugIsFree(slugIsFree), "name", "A page already exists with this title")
form.CheckField(validator.NotBlank(form.Body), "body", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.Summary), "summary", "This field cannot be blank")
// form.CheckField(validator.NotBlankInt(form.Host), "host", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.Category), "category", "This field cannot be blank")
if !form.Valid() {
data := app.newTemplateData(r)
// form.Slug = pathSlug
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "edit.tmpl", data)
return
}
now := time.Now()
created, err := time.Parse("2006-01-02T15:04", form.Created)
if err != nil {
app.serverError(w, r, err)
return
}
err = app.pages.Update(form.Name, slug, form.Host, form.Category, form.Summary, form.Body, created, now, form.Private, form.Menu, form.Feed, form.Id)
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "flash", "Page successfully updated!")
http.Redirect(w, r, "/"+slug, http.StatusSeeOther)
}
func (app *application) PageDeletePost(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil || id < 1 {
http.NotFound(w, r)
return
}
err = r.ParseForm()
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
// Decode the form
var form pageCreateForm
err = app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
var redirect = ""
if form.Host != 0 {
hostPage, err := app.pages.GetHostSlug(form.Host)
if err != nil {
app.serverError(w, r, err)
return
}
redirect = hostPage.Slug
}
err = app.pages.Delete(id)
if err != nil {
app.serverError(w, r, err)
return
}
app.sessionManager.Put(r.Context(), "flash", "Children page successfully deleted!")
http.Redirect(w, r, "/"+redirect, http.StatusSeeOther)
}
//----- USER ROUTES ------ //
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
// If a user already exists, refuse to serve the page
exists, err := app.users.AnyUserExist()
if err != nil {
app.serverError(w, r, err)
return
}
if exists {
http.NotFound(w, r)
return
}
data := app.newTemplateData(r)
data.Form = userSignupForm{}
app.render(w, r, http.StatusOK, "signup.tmpl", data)
}
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
// If a user already exists, refuse to serve the page
exists, err := app.users.AnyUserExist()
if err != nil {
app.serverError(w, r, err)
return
}
if exists {
http.NotFound(w, r)
return
}
var form userSignupForm
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, 12), "password", "This field must be at least 12 characters long")
form.CheckField(validator.MaxBytes(form.Password, 72), "password", "This field must not be more than 72 bytes long")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "signup.tmpl", data)
return
}
err = app.users.Insert(form.Name, form.Email, form.Password)
if err != nil {
if errors.Is(err, models.ErrDuplicateEmail) {
form.AddFieldError("email", "Email address is already used")
data := app.newTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "signup.tmpl", data)
} else {
app.serverError(w, r, err)
}
return
}
app.sessionManager.Put(r.Context(), "flash", "Your signup was successful. You can now log in.")
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
}
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = userLoginInForm{}
app.render(w, r, http.StatusOK, "login.tmpl", data)
}
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
var form userLoginInForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
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 = form
app.render(w, r, http.StatusUnprocessableEntity, "login.tmpl", 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.tmpl", 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)
path := app.sessionManager.PopString(r.Context(), "redirectPathAfterLogin")
if path != "" {
http.Redirect(w, r, path, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) userLogoutPost(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) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) {
data := app.newTemplateData(r)
data.Form = accountPasswordUpdateForm{}
app.render(w, r, http.StatusOK, "password.tmpl", data)
}
func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
var form accountPasswordUpdateForm
err := app.decodePostForm(r, &form)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank")
form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank")
form.CheckField(validator.MinChars(form.NewPassword, 12), "newPassword", "This field must be at least 12 characters long")
form.CheckField(validator.MaxBytes(form.NewPassword, 72), "password", "This field must not be more than 72 bytes long")
form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank")
form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match")
if !form.Valid() {
data := app.newTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data)
return
}
userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
err = app.users.PasswordUpdate(userID, form.CurrentPassword, form.NewPassword)
if err != nil {
if errors.Is(err, models.ErrInvalidCredentials) {
form.AddFieldError("currentPassword", "Current password is incorrect")
data := app.newTemplateData(r)
data.Form = form
app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data)
} else {
app.serverError(w, r, err)
}
return
}
app.sessionManager.Put(r.Context(), "flash", "Your password has been updated!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (app *application) uploadFiles(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
app.serverError(w, r, err)
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
app.clientErrorMessage(w, "0 files", http.StatusBadRequest)
return
}
filesDir := "./uploaded"
processor := NewImageProcessor()
var sanitizedFilenames []string
for _, fileHeader := range files {
safeFilename := app.sanitizeFilename(fileHeader.Filename)
if err := app.processFile(fileHeader, safeFilename, filesDir, processor); err != nil {
app.serverError(w, r, err)
return
}
extension := strings.ToLower(filepath.Ext(safeFilename))
if extension == ".heif" || extension == ".heic" {
baseName := strings.TrimSuffix(safeFilename, filepath.Ext(safeFilename))
safeFilename = baseName + ".jpg"
}
sanitizedFilenames = append(sanitizedFilenames, safeFilename)
}
// Return the sanitized filenames as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string][]string{"filenames": sanitizedFilenames})
}
func (app *application) deleteFile(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
app.clientError(w, http.StatusBadRequest)
return
}
filePath := filepath.Join("./uploaded", slug)
// Validate path is within uploaded directory
if !strings.HasPrefix(filepath.Clean(filePath), filepath.Clean("./uploaded")) {
app.clientError(w, http.StatusBadRequest)
return
}
// Try to delete the original file directly
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
http.NotFound(w, r)
} else {
app.serverError(w, r, err)
}
return
}
// If it's an image file, also delete processed versions from processed folder
if app.isImageFile(slug) {
baseFilename := strings.TrimSuffix(slug, filepath.Ext(slug))
// Try to delete processed versions directly (ignore errors)
os.Remove(filepath.Join("./processed", baseFilename+".jpg"))
os.Remove(filepath.Join("./processed", baseFilename+".webp"))
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("File deleted successfully"))
}
func (app *application) prewiewText(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
app.clientError(w, http.StatusBadRequest)
return
}
parsed := kaku.Parse(string(body)).Render()
w.Write([]byte(parsed))
}
func (app *application) setTheme(w http.ResponseWriter, r *http.Request) {
theme := r.URL.Query().Get("theme")
http.SetCookie(w, &http.Cookie{
Name: "theme",
Value: theme,
Path: "/",
MaxAge: 60 * 60 * 24 * 365,
SameSite: http.SameSiteLaxMode,
Secure: true,
HttpOnly: false,
})
next := r.URL.Query().Get("next")
if !app.isSafeLocalPath(next) {
next = "/"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}