Compare commits
66 Commits
Author | SHA1 | Date | |
---|---|---|---|
0fb805dd97 | |||
5688162a5b | |||
99bac19300 | |||
17d25da9d4 | |||
5e7524196a | |||
7a1952b100 | |||
d26f309cf5 | |||
65801464f1 | |||
bcbba7e872 | |||
dae0108589 | |||
2759127cf9 | |||
f6e332b76a | |||
db1d4e1ad2 | |||
c56a445c6a | |||
58e6f35585 | |||
133d8bcfe9 | |||
a15bbcee3d | |||
c3b10ae239 | |||
306053b1e3 | |||
a72d32850b | |||
89be6fa34d | |||
66cf07a024 | |||
861c953d01 | |||
b9842ddd5e | |||
d574dab3a7 | |||
1983662216 | |||
8b6ae897a5 | |||
4415ed90e8 | |||
d009bd98ac | |||
a08a6b3f41 | |||
c3f08fb745 | |||
019b31aa9f | |||
260ddbe740 | |||
1a41a94b41 | |||
3c811c9533 | |||
8b681922f6 | |||
2b9d0e11b8 | |||
3ce55bb870 | |||
82c00b9a01 | |||
8222af6d98 | |||
2c10c1f26a | |||
025f273f48 | |||
f239b17255 | |||
1b44d23dd6 | |||
a41244fb62 | |||
64ff095002 | |||
303e51f53e | |||
e065d5630c | |||
fa5507e719 | |||
7537fa2e92 | |||
365e727284 | |||
672dca5bb9 | |||
c8ff7021c8 | |||
11c0815676 | |||
6c18752230 | |||
36bfde1e4d | |||
2a15f4b91d | |||
adaf6cf87d | |||
5c8817aa2a | |||
b58ea19a86 | |||
e658f15463 | |||
d899769ba0 | |||
19364225c9 | |||
e54875f943 | |||
741b304032 | |||
4fc9bb5c1f |
14
.gitignore
vendored
14
.gitignore
vendored
@ -21,3 +21,17 @@
|
|||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
# sqlite3 databases
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# air config
|
||||||
|
.air.toml
|
||||||
|
/tmp
|
||||||
|
tls/
|
||||||
|
test.db.old
|
||||||
|
.gitignore
|
||||||
|
.nvim/session
|
||||||
|
*templ.txt
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
[submodule "ui/static/fontawesome"]
|
||||||
|
path = ui/static/fontawesome
|
||||||
|
url = https://github.com/FortAwesome/Font-Awesome.git
|
||||||
|
branch = fa-release-7.0.0
|
6
cmd/web/context.go
Normal file
6
cmd/web/context.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const isAuthenticatedContextKey = contextKey("isAuthenticated")
|
||||||
|
const userNameContextKey = contextKey("username")
|
27
cmd/web/handlers.go
Normal file
27
cmd/web/handlers.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/ui/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *application) home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if app.isAuthenticated(r) {
|
||||||
|
http.Redirect(w, r, "/websites", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views.Home("Home", app.newCommonData(r)).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) about(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views.AboutPage("About Webweav.ing", app.newCommonData(r)).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) notImplemented(w http.ResponseWriter, r *http.Request) {
|
||||||
|
views.ComingSoon("Coming Soon", app.newCommonData(r)).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ping(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
}
|
363
cmd/web/handlers_guestbook.go
Normal file
363
cmd/web/handlers_guestbook.go
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/validator"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/ui/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *application) getGuestbook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !website.Guestbook.Settings.IsVisible {
|
||||||
|
u := app.getCurrentUser(r)
|
||||||
|
if u == nil {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if u.ID != website.UserId {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comments, err := app.guestbookComments.GetVisible(website.Guestbook.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.GuestbookView(website.Name, data, website, website.Guestbook, comments, forms.CommentCreateForm{}).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getGuestbookComments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.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(website.Guestbook.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.GuestbookDashboardCommentsView(fmt.Sprintf("%s - Comments", website.Name), data, website, website.Guestbook, comments).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getGuestbookCommentsSerialized(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !website.Guestbook.Settings.IsVisible || !website.Guestbook.Settings.AllowRemoteHostAccess {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
comments, err := app.guestbookComments.GetVisibleSerialized(website.Guestbook.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(comments)
|
||||||
|
w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: This will be the embeddable form
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := website.Guestbook.Settings
|
||||||
|
if !s.IsVisible || !s.AllowRemoteHostAccess || !website.Guestbook.CanComment() {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
form := forms.CommentCreateForm{}
|
||||||
|
views.EmbeddableGuestbookCommentForm(data, website, form).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) postGuestbookCommentCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
headless, err := strconv.ParseBool(r.URL.Query().Get("headless"))
|
||||||
|
if err != nil {
|
||||||
|
headless = false
|
||||||
|
}
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !website.Guestbook.CanComment() {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var form forms.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.MaxChars(form.AuthorEmail, 256), "authorEmail", "This field cannot be more than 256 characters long")
|
||||||
|
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.newCommonData(r)
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
if headless {
|
||||||
|
views.EmbeddableGuestbookCommentForm(data, website, form).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
// TODO: use htmx to avoid getting comments again
|
||||||
|
comments, err := app.guestbookComments.GetVisible(website.Guestbook.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views.GuestbookView("Guestbook", data, website, website.Guestbook, comments, form).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shortId := app.createShortId()
|
||||||
|
_, err = app.guestbookComments.Insert(shortId, website.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!")
|
||||||
|
if headless {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/websites/%s/guestbook/comments/create", slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/websites/%s/guestbook", slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) postGuestbookCommentCreateRemote(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !matchOrigin(r.Header.Get("Origin"), website.Url) {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !website.Guestbook.CanComment() {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var form forms.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.MaxChars(form.AuthorEmail, 256), "authorEmail", "This field cannot be more than 256 characters long")
|
||||||
|
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 redirect path is filled out, redirect to that path on the website host
|
||||||
|
// otherwise redirect to the guestbook by default
|
||||||
|
redirectUrl := fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(website.ShortId))
|
||||||
|
if form.Redirect != "" {
|
||||||
|
u, err := website.Url.Parse(form.Redirect)
|
||||||
|
if err == nil {
|
||||||
|
redirectUrl = u.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !form.Valid() {
|
||||||
|
views.GuestbookCommentCreateRemoteErrorView(redirectUrl, "Invalid Input").Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shortId := app.createShortId()
|
||||||
|
_, err = app.guestbookComments.Insert(shortId, website.Guestbook.ID, 0, form.AuthorName, form.AuthorEmail, form.AuthorSite, form.Content, "", true)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
views.GuestbookCommentCreateRemoteSuccessView(redirectUrl).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getCommentQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.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(website.Guestbook.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.GuestbookDashboardCommentsView(fmt.Sprintf("%s - Comment Queue", website.Name), data, website, website.Guestbook, comments).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getCommentTrash(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.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.GetDeleted(website.Guestbook.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.GuestbookDashboardCommentsView(fmt.Sprintf("%s - Comment Trash", website.Name), data, website, website.Guestbook, comments).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) putHideGuestbookComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := app.getCurrentUser(r)
|
||||||
|
wSlug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(wSlug))
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Info("website 404")
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.ID != website.UserId {
|
||||||
|
app.clientError(w, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
cSlug := r.PathValue("commentId")
|
||||||
|
comment, err := app.guestbookComments.Get(slugToShortId(cSlug))
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Info("comment 404")
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comment.IsPublished = !comment.IsPublished
|
||||||
|
err = app.guestbookComments.UpdateComment(&comment)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.GuestbookDashboardUpdateButtonPart(data, website, comment).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) deleteGuestbookComment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := app.getCurrentUser(r)
|
||||||
|
wSlug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(wSlug))
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Info("website 404")
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.ID != website.UserId {
|
||||||
|
app.clientError(w, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
cSlug := r.PathValue("commentId")
|
||||||
|
comment, err := app.guestbookComments.Get(slugToShortId(cSlug))
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Info("comment 404")
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comment.Deleted = time.Now().UTC()
|
||||||
|
err = app.guestbookComments.UpdateComment(&comment)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
views.GuestbookDashboardCommentDeletePart("Comment was successfully deleted").Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getAllGuestbooks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
websites, err := app.websites.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
views.AllGuestbooksView(app.newCommonData(r), websites).Render(r.Context(), w)
|
||||||
|
}
|
249
cmd/web/handlers_guestbook_test.go
Normal file
249
cmd/web/handlers_guestbook_test.go
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPing(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
code, _, body := ts.get(t, "/ping")
|
||||||
|
|
||||||
|
assert.Equal(t, code, http.StatusOK)
|
||||||
|
assert.Equal(t, body, "OK")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetGuestbookView(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
urlPath string
|
||||||
|
wantCode int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid id",
|
||||||
|
urlPath: fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1)),
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantBody: "Guestbook for Example",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-existent ID",
|
||||||
|
urlPath: fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(2)),
|
||||||
|
wantCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "String ID",
|
||||||
|
urlPath: "/websites/abcd/guestbook",
|
||||||
|
wantCode: http.StatusNotFound,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
code, _, body := ts.get(t, tt.urlPath)
|
||||||
|
assert.Equal(t, code, tt.wantCode)
|
||||||
|
if tt.wantBody != "" {
|
||||||
|
assert.StringContains(t, body, tt.wantBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostGuestbookCommentCreate(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
_, _, body := ts.get(t, fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1)))
|
||||||
|
validCSRFToken := extractCSRFToken(t, body)
|
||||||
|
|
||||||
|
const (
|
||||||
|
validAuthorName = "John Test"
|
||||||
|
validAuthorEmail = "test@example.com"
|
||||||
|
validAuthorSite = "example.com"
|
||||||
|
validContent = "This is a comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authorName string
|
||||||
|
authorEmail string
|
||||||
|
authorSite string
|
||||||
|
content string
|
||||||
|
csrfToken string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid input",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: validContent,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank name",
|
||||||
|
authorName: "",
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: validContent,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank email",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: "",
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: validContent,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank site",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: "",
|
||||||
|
content: validContent,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank content",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: "",
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("authorname", tt.authorName)
|
||||||
|
form.Add("authoremail", tt.authorEmail)
|
||||||
|
form.Add("authorsite", tt.authorSite)
|
||||||
|
form.Add("content", tt.content)
|
||||||
|
form.Add("csrf_token", tt.csrfToken)
|
||||||
|
code, _, body := ts.postForm(t, fmt.Sprintf("/websites/%s/guestbook/comments/create", shortIdToSlug(1)), form)
|
||||||
|
assert.Equal(t, code, tt.wantCode)
|
||||||
|
assert.Equal(t, body, body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostGuestbookCommentCreateRemote(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
const (
|
||||||
|
validAuthorName = "John Test"
|
||||||
|
validAuthorEmail = "test@example.com"
|
||||||
|
validAuthorSite = "example.com"
|
||||||
|
validContent = "This is a comment"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
authorName string
|
||||||
|
authorEmail string
|
||||||
|
authorSite string
|
||||||
|
content string
|
||||||
|
wantCode int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid input",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: validContent,
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantBody: "Comment successfully posted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank name",
|
||||||
|
authorName: "",
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: validContent,
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantBody: "An error occurred",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank email",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: "",
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: validContent,
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantBody: "Comment successfully posted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank site",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: "",
|
||||||
|
content: validContent,
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantBody: "Comment successfully posted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blank content",
|
||||||
|
authorName: validAuthorName,
|
||||||
|
authorEmail: validAuthorEmail,
|
||||||
|
authorSite: validAuthorSite,
|
||||||
|
content: "",
|
||||||
|
wantCode: http.StatusOK,
|
||||||
|
wantBody: "An error occurred",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("authorname", tt.authorName)
|
||||||
|
form.Add("authoremail", tt.authorEmail)
|
||||||
|
form.Add("authorsite", tt.authorSite)
|
||||||
|
form.Add("content", tt.content)
|
||||||
|
r, err := http.NewRequest("POST", ts.URL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.URL.Path = fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1))
|
||||||
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
r.Header.Set("Origin", "http://example.com")
|
||||||
|
|
||||||
|
resp, err := ts.Client().Do(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body = bytes.TrimSpace(body)
|
||||||
|
assert.Equal(t, resp.StatusCode, tt.wantCode)
|
||||||
|
assert.StringContains(t, string(body), tt.wantBody)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
270
cmd/web/handlers_user.go
Normal file
270
cmd/web/handlers_user.go
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/validator"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/ui/views"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !app.config.localAuthEnabled {
|
||||||
|
http.Redirect(w, r, "/users/login/oidc", http.StatusFound)
|
||||||
|
}
|
||||||
|
form := forms.UserRegistrationForm{}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.UserRegistration("User Registration", data, form).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !app.config.localAuthEnabled {
|
||||||
|
http.Redirect(w, r, "/users/login/oidc", http.StatusFound)
|
||||||
|
}
|
||||||
|
views.UserLogin("Login", app.newCommonData(r), forms.UserLoginForm{}).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) postUserRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var form forms.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.newCommonData(r)
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
views.UserRegistration("User Registration", data, form).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shortId := app.createShortId()
|
||||||
|
settings := DefaultUserSettings()
|
||||||
|
err = app.users.Insert(shortId, form.Name, form.Email, form.Password, settings)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrDuplicateEmail) {
|
||||||
|
form.AddFieldError("email", "Email address is already in use")
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
views.UserRegistration("User Registration", data, form).Render(r.Context(), w)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var form forms.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.newCommonData(r)
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
views.UserLogin("Login", data, form).Render(r.Context(), w)
|
||||||
|
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.newCommonData(r)
|
||||||
|
views.UserLogin("Login", data, form).Render(r.Context(), w)
|
||||||
|
} 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) userLoginOIDC(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !app.config.oauthEnabled {
|
||||||
|
http.Redirect(w, r, "/users/login", http.StatusFound)
|
||||||
|
}
|
||||||
|
state, err := randString(16)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nonce, err := randString(16)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallbackCookie(w, r, "state", state)
|
||||||
|
setCallbackCookie(w, r, "nonce", nonce)
|
||||||
|
|
||||||
|
http.Redirect(w, r, app.config.oauth.config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) userLoginOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
state, err := r.Cookie("state")
|
||||||
|
if err != nil {
|
||||||
|
app.clientError(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("state") != state.Value {
|
||||||
|
app.clientError(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth2Token, err := app.config.oauth.config.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Error("Failed to exchange token")
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
|
||||||
|
if !ok {
|
||||||
|
app.serverError(w, r, errors.New("No id_token field in oauth2 token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idToken, err := app.config.oauth.verifier.Verify(r.Context(), rawIDToken)
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Error("Failed to verify ID token")
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nonce, err := r.Cookie("nonce")
|
||||||
|
if err != nil {
|
||||||
|
app.logger.Error("nonce not found")
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idToken.Nonce != nonce.Value {
|
||||||
|
app.serverError(w, r, errors.New("nonce did not match"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauth2Token.AccessToken = "*REDACTED*"
|
||||||
|
|
||||||
|
var t models.UserIdToken
|
||||||
|
if err := idToken.Claims(&t); err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.sessionManager.RenewToken(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// search for user by subject
|
||||||
|
id, err := app.users.GetBySubject(t.Subject)
|
||||||
|
if err != nil && errors.Is(err, models.ErrNoRecord) {
|
||||||
|
// if no user is found, check if they have signed up by email already
|
||||||
|
id, err = app.users.GetByEmail(t.Email)
|
||||||
|
if err == nil {
|
||||||
|
// if user is found by email, update subject to match them in the first step next time
|
||||||
|
err2 := app.users.UpdateSubject(id, t.Subject)
|
||||||
|
if err2 != nil {
|
||||||
|
app.serverError(w, r, err2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil && errors.Is(err, models.ErrNoRecord) {
|
||||||
|
// if no user is found by subject or email, create a new user
|
||||||
|
id, err = app.users.InsertWithoutPassword(app.createShortId(), t.Username, t.Email, t.Subject, DefaultUserSettings())
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
} else 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!")
|
||||||
|
w.Header().Add("HX-Redirect", "/")
|
||||||
|
// http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.newCommonData(r)
|
||||||
|
views.UserProfile(user.Username, data, user).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.UserSettingsView(data, app.timezones).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) putUserSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := app.getCurrentUser(r)
|
||||||
|
var form forms.UserSettingsForm
|
||||||
|
err := app.decodePostForm(r, &form)
|
||||||
|
if err != nil {
|
||||||
|
app.clientError(w, http.StatusBadRequest)
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.CheckField(validator.PermittedValue(form.LocalTimezone, app.timezones...), "timezone", "Invalid value")
|
||||||
|
if !form.Valid() {
|
||||||
|
// TODO: rerender template with errors
|
||||||
|
app.clientError(w, http.StatusUnprocessableEntity)
|
||||||
|
}
|
||||||
|
user.Settings.LocalTimezone, err = time.LoadLocation(form.LocalTimezone)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = app.users.UpdateUserSettings(user.ID, user.Settings)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.sessionManager.Put(r.Context(), "flash", "Settings changed successfully")
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
w.Header().Add("HX-Refresh", "true")
|
||||||
|
views.UserSettingsView(data, app.timezones).Render(r.Context(), w)
|
||||||
|
|
||||||
|
}
|
286
cmd/web/handlers_user_test.go
Normal file
286
cmd/web/handlers_user_test.go
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserSignup(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
_, _, body := ts.get(t, "/users/register")
|
||||||
|
validCSRFToken := extractCSRFToken(t, body)
|
||||||
|
|
||||||
|
const (
|
||||||
|
validName = "John"
|
||||||
|
validPassword = "validPassword"
|
||||||
|
validEmail = "john@example.com"
|
||||||
|
formTag = `<form action="/users/register" method="post">`
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
userName string
|
||||||
|
userEmail string
|
||||||
|
userPassword string
|
||||||
|
csrfToken string
|
||||||
|
wantCode int
|
||||||
|
wantFormTag string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid submission",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: validEmail,
|
||||||
|
userPassword: validPassword,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing token",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: validEmail,
|
||||||
|
userPassword: validPassword,
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty name",
|
||||||
|
userName: "",
|
||||||
|
userEmail: validEmail,
|
||||||
|
userPassword: validPassword,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
wantFormTag: formTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty email",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: "",
|
||||||
|
userPassword: validPassword,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
wantFormTag: formTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty password",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: validEmail,
|
||||||
|
userPassword: "",
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
wantFormTag: formTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid email",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: "asdfasdf",
|
||||||
|
userPassword: validPassword,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
wantFormTag: formTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid password",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: validEmail,
|
||||||
|
userPassword: "asdfasd",
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
wantFormTag: formTag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Duplicate email",
|
||||||
|
userName: validName,
|
||||||
|
userEmail: "dupe@example.com",
|
||||||
|
userPassword: validPassword,
|
||||||
|
csrfToken: validCSRFToken,
|
||||||
|
wantCode: http.StatusUnprocessableEntity,
|
||||||
|
wantFormTag: formTag,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(*testing.T) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Add("username", tt.userName)
|
||||||
|
form.Add("email", tt.userEmail)
|
||||||
|
form.Add("password", tt.userPassword)
|
||||||
|
form.Add("csrf_token", tt.csrfToken)
|
||||||
|
code, _, body := ts.postForm(t, "/users/register", form)
|
||||||
|
assert.Equal(t, code, tt.wantCode)
|
||||||
|
if tt.wantFormTag != "" {
|
||||||
|
assert.StringContains(t, body, tt.wantFormTag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuth2Mock struct {
|
||||||
|
Srv *testServer
|
||||||
|
Priv *rsa.PrivateKey
|
||||||
|
Subject string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuth2Mock) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuth2Mock) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
|
||||||
|
tkn := oauth2.Token{
|
||||||
|
AccessToken: "AccessToken",
|
||||||
|
Expiry: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
m := make(map[string]any)
|
||||||
|
var rawClaims = `{
|
||||||
|
"iss": "` + o.Srv.URL + `",
|
||||||
|
"aud": "my-client-id",
|
||||||
|
"sub": "` + o.Subject + `",
|
||||||
|
"email": "` + o.Email + `",
|
||||||
|
"email_verified": true,
|
||||||
|
"nonce": "nonce"
|
||||||
|
}`
|
||||||
|
|
||||||
|
m["id_token"] = oidctest.SignIDToken(o.Priv, "test-key", oidc.RS256, rawClaims)
|
||||||
|
return tkn.WithExtra(m), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *OAuth2Mock) Client(ctx context.Context, t *oauth2.Token) *http.Client {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserOIDCCallback(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
|
||||||
|
priv := newTestKey(t)
|
||||||
|
srv := newTestOIDCServer(t, priv)
|
||||||
|
|
||||||
|
defer srv.Close()
|
||||||
|
defer ts.Close()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
p, err := oidc.NewProvider(ctx, srv.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &oidc.Config{
|
||||||
|
ClientID: "my-client-id",
|
||||||
|
SkipExpiryCheck: true,
|
||||||
|
}
|
||||||
|
v := p.VerifierContext(ctx, cfg)
|
||||||
|
oMock := &OAuth2Mock{
|
||||||
|
Srv: srv,
|
||||||
|
Priv: priv,
|
||||||
|
Subject: "foo",
|
||||||
|
}
|
||||||
|
app.config.oauth = applicationOauthConfig{
|
||||||
|
ctx: context.Background(),
|
||||||
|
oidcConfig: cfg,
|
||||||
|
config: oMock,
|
||||||
|
provider: p,
|
||||||
|
verifier: v,
|
||||||
|
}
|
||||||
|
app.config.oauthEnabled = true
|
||||||
|
|
||||||
|
const (
|
||||||
|
validSubject = "goodSubject"
|
||||||
|
unknownSubject = "foo"
|
||||||
|
validUserId = 1
|
||||||
|
validEmail = "test@example.com"
|
||||||
|
validState = "goodState"
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subject string
|
||||||
|
email string
|
||||||
|
state string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "By Subject",
|
||||||
|
subject: validSubject,
|
||||||
|
email: "",
|
||||||
|
state: validState,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "By Email",
|
||||||
|
subject: unknownSubject,
|
||||||
|
email: validEmail,
|
||||||
|
state: validState,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No User",
|
||||||
|
subject: unknownSubject,
|
||||||
|
email: "",
|
||||||
|
state: validState,
|
||||||
|
wantCode: http.StatusSeeOther,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid State",
|
||||||
|
subject: unknownSubject,
|
||||||
|
email: validEmail,
|
||||||
|
state: "",
|
||||||
|
wantCode: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unknown Subject & Email",
|
||||||
|
subject: unknownSubject,
|
||||||
|
email: "",
|
||||||
|
state: validState,
|
||||||
|
wantCode: http.StatusInternalServerError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(*testing.T) {
|
||||||
|
oMock.Subject = tt.subject
|
||||||
|
oMock.Email = tt.email
|
||||||
|
r, err := http.NewRequest("GET", ts.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r.URL.Path = "/users/login/oidc/callback"
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Add("state", tt.state)
|
||||||
|
r.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
c := &http.Cookie{
|
||||||
|
Name: "state",
|
||||||
|
Value: validState,
|
||||||
|
MaxAge: int(time.Hour.Seconds()),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
}
|
||||||
|
d := &http.Cookie{
|
||||||
|
Name: "nonce",
|
||||||
|
Value: "nonce",
|
||||||
|
MaxAge: int(time.Hour.Seconds()),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
}
|
||||||
|
r.AddCookie(c)
|
||||||
|
r.AddCookie(d)
|
||||||
|
resp, err := ts.Client().Do(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, resp.StatusCode, tt.wantCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
225
cmd/web/handlers_website.go
Normal file
225
cmd/web/handlers_website.go
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/validator"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/ui/views"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *application) getWebsiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
form := forms.WebsiteCreateForm{}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.WebsiteCreate("Add Website", data, form).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) postWebsiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userId := app.sessionManager.GetInt64(r.Context(), "authenticatedUserId")
|
||||||
|
var form forms.WebsiteCreateForm
|
||||||
|
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 exceed 256 characters")
|
||||||
|
form.CheckField(validator.NotBlank(form.Name), "sitename", "This field cannot be blank")
|
||||||
|
form.CheckField(validator.MaxChars(form.Name, 256), "sitename", "This field cannot exceed 256 characters")
|
||||||
|
form.CheckField(validator.NotBlank(form.SiteUrl), "siteurl", "This field cannot be blank")
|
||||||
|
form.CheckField(validator.MaxChars(form.SiteUrl, 512), "siteurl", "This field cannot exceed 512 characters")
|
||||||
|
form.CheckField(validator.Matches(form.SiteUrl, validator.WebRX), "siteurl", "This field must be a valid URL (including http:// or https://)")
|
||||||
|
|
||||||
|
u, err := url.Parse(form.SiteUrl)
|
||||||
|
if err != nil {
|
||||||
|
form.CheckField(false, "siteurl", "This field must be a valid URL")
|
||||||
|
}
|
||||||
|
if !form.Valid() {
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||||
|
views.WebsiteCreate("Add a Website", data, form).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
websiteShortID := app.createShortId()
|
||||||
|
_, err = app.websites.Insert(websiteShortID, userId, form.Name, u.String(), form.AuthorName)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.sessionManager.Put(r.Context(), "flash", "Website successfully registered!")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/websites/%s/dashboard", shortIdToSlug(websiteShortID)), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getWebsiteDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
user := app.getCurrentUser(r)
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.ID != website.UserId {
|
||||||
|
app.clientError(w, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.WebsiteDashboard(fmt.Sprintf("%s - Dashboard", website.Name), data, website).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getWebsiteList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
userId := app.sessionManager.GetInt64(r.Context(), "authenticatedUserId")
|
||||||
|
websites, err := app.websites.GetAllUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.WebsiteList("My Websites", data, websites).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getComingSoon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
user := app.getCurrentUser(r)
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if website.UserId != user.ID {
|
||||||
|
app.clientError(w, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
views.WebsiteDashboardComingSoon("Coming Soon", app.newCommonData(r), website).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getWebsiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var form forms.WebsiteSettingsForm
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.WebsiteDashboardSettings(data, website, form).Render(r.Context(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) putWebsiteSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var form forms.WebsiteSettingsForm
|
||||||
|
err = app.decodePostForm(r, &form)
|
||||||
|
if err != nil {
|
||||||
|
app.clientError(w, http.StatusBadRequest)
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.CheckField(validator.NotBlank(form.AuthorName), "ws_author", "This field cannot be blank")
|
||||||
|
form.CheckField(validator.MaxChars(form.AuthorName, 256), "ws_author", "This field cannot exceed 256 characters")
|
||||||
|
form.CheckField(validator.NotBlank(form.SiteName), "ws_name", "This field cannot be blank")
|
||||||
|
form.CheckField(validator.MaxChars(form.SiteName, 256), "ws_name", "This field cannot exceed 256 characters")
|
||||||
|
form.CheckField(validator.NotBlank(form.SiteUrl), "ws_url", "This field cannot be blank")
|
||||||
|
form.CheckField(validator.MaxChars(form.SiteUrl, 512), "ws_url", "This field cannot exceed 512 characters")
|
||||||
|
form.CheckField(validator.Matches(form.SiteUrl, validator.WebRX), "ws_url", "This field must be a valid URL (including http:// or https://)")
|
||||||
|
form.CheckField(validator.PermittedValue(form.Visibility, "true", "false"), "gb_visible", "Invalid value")
|
||||||
|
form.CheckField(validator.PermittedValue(form.CommentingEnabled, models.ValidDisableDurations...), "gb_visible", "Invalid value")
|
||||||
|
form.CheckField(validator.PermittedValue(form.WidgetsEnabled, "true", "false"), "gb_remote", "Invalid value")
|
||||||
|
if !form.Valid() {
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.SettingsForm(data, website, form, "").Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gbSettings, err := convertFormToGuestbookSettings(form)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.websites.UpdateGuestbookSettings(website.Guestbook.ID, gbSettings)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(form.SiteUrl)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
website.Name = form.SiteName
|
||||||
|
website.AuthorName = form.AuthorName
|
||||||
|
website.Url = u
|
||||||
|
|
||||||
|
err = app.websites.Update(website)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.sessionManager.Put(r.Context(), "flash", "Settings changed successfully")
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.SettingsForm(data, website, forms.WebsiteSettingsForm{}, "Settings changed successfully").Render(r.Context(), w)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) deleteWebsite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
slug := r.PathValue("id")
|
||||||
|
website, err := app.websites.Get(slugToShortId(slug))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, models.ErrNoRecord) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
} else {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var form forms.WebsiteDeleteForm
|
||||||
|
err = app.decodePostForm(r, &form)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form.CheckField(validator.Equals(website.Name, form.Delete), "delete", "Input must match site name exactly")
|
||||||
|
if !form.Valid() {
|
||||||
|
data := app.newCommonData(r)
|
||||||
|
views.DeleteForm(data, website, form).Render(r.Context(), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.websites.Delete(website.ID)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.sessionManager.Put(r.Context(), "flash", "Website Deleted")
|
||||||
|
w.Header().Add("HX-Redirect", "/websites")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
192
cmd/web/helpers.go
Normal file
192
cmd/web/helpers.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/ui/views"
|
||||||
|
"github.com/gorilla/schema"
|
||||||
|
"github.com/justinas/nosurf"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
if app.debug {
|
||||||
|
http.Error(w, err.Error()+"\n"+string(debug.Stack()), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
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) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) getCurrentUser(r *http.Request) *models.User {
|
||||||
|
if !app.isAuthenticated(r) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
user, ok := r.Context().Value(userNameContextKey).(models.User)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &user
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) newCommonData(r *http.Request) views.CommonData {
|
||||||
|
return views.CommonData{
|
||||||
|
CurrentYear: time.Now().Year(),
|
||||||
|
Flash: app.sessionManager.PopString(r.Context(), "flash"),
|
||||||
|
IsAuthenticated: app.isAuthenticated(r),
|
||||||
|
CSRFToken: nosurf.Token(r),
|
||||||
|
CurrentUser: app.getCurrentUser(r),
|
||||||
|
IsHtmx: r.Header.Get("Hx-Request") == "true",
|
||||||
|
RootUrl: app.config.rootUrl,
|
||||||
|
LocalAuthEnabled: app.config.localAuthEnabled,
|
||||||
|
OIDCEnabled: app.config.oauthEnabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultUserSettings() models.UserSettings {
|
||||||
|
return models.UserSettings{
|
||||||
|
LocalTimezone: time.Now().UTC().Location(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationToTime(duration string) (time.Time, error) {
|
||||||
|
var result time.Time
|
||||||
|
offset, err := time.ParseDuration(duration)
|
||||||
|
if err != nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
result = time.Now().UTC().Add(offset)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchOrigin(origin string, u *url.URL) bool {
|
||||||
|
o, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if o.Host != u.Host {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func randString(nByte int) (string, error) {
|
||||||
|
b := make([]byte, nByte)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
|
||||||
|
c := &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
MaxAge: int(time.Hour.Seconds()),
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
HttpOnly: true,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertFormToGuestbookSettings(form forms.WebsiteSettingsForm) (models.GuestbookSettings, error) {
|
||||||
|
var s models.GuestbookSettings
|
||||||
|
c, err := strconv.ParseBool(form.CommentingEnabled)
|
||||||
|
if err != nil {
|
||||||
|
s.IsCommentingEnabled = false
|
||||||
|
s.ReenableCommenting, err = durationToTime(form.CommentingEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.IsCommentingEnabled = c
|
||||||
|
}
|
||||||
|
|
||||||
|
// can skip error checking for these two since we verify valid values above
|
||||||
|
s.IsVisible, err = strconv.ParseBool(form.Visibility)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
s.AllowRemoteHostAccess, err = strconv.ParseBool(form.WidgetsEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
275
cmd/web/main.go
Normal file
275
cmd/web/main.go
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/auth"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
"github.com/alexedwards/scs/sqlite3store"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/gorilla/schema"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type applicationOauthConfig struct {
|
||||||
|
ctx context.Context
|
||||||
|
oidcConfig *oidc.Config
|
||||||
|
config auth.OAuth2ConfigInterface
|
||||||
|
provider *oidc.Provider
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
}
|
||||||
|
|
||||||
|
type applicationConfig struct {
|
||||||
|
oauthEnabled bool
|
||||||
|
localAuthEnabled bool
|
||||||
|
oauth applicationOauthConfig
|
||||||
|
rootUrl string
|
||||||
|
environment string
|
||||||
|
}
|
||||||
|
|
||||||
|
type application struct {
|
||||||
|
sequence uint16
|
||||||
|
logger *slog.Logger
|
||||||
|
websites models.WebsiteModelInterface
|
||||||
|
users models.UserModelInterface
|
||||||
|
guestbookComments models.GuestbookCommentModelInterface
|
||||||
|
sessionManager *scs.SessionManager
|
||||||
|
formDecoder *schema.Decoder
|
||||||
|
config applicationConfig
|
||||||
|
debug bool
|
||||||
|
timezones []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := flag.String("addr", ":3000", "HTTP network address")
|
||||||
|
dsn := flag.String("dsn", "guestbook.db", "data source name")
|
||||||
|
debug := flag.Bool("debug", false, "enable debug mode")
|
||||||
|
env := flag.String("env", ".env", ".env file path")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
err := godotenv.Load(*env)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cfg, err := setupConfig(*addr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openDB(*dsn)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
sessionManager := scs.New()
|
||||||
|
sessionManager.Store = sqlite3store.New(db)
|
||||||
|
sessionManager.Lifetime = 12 * time.Hour
|
||||||
|
|
||||||
|
formDecoder := schema.NewDecoder()
|
||||||
|
formDecoder.IgnoreUnknownKeys(true)
|
||||||
|
|
||||||
|
app := &application{
|
||||||
|
sequence: 0,
|
||||||
|
logger: logger,
|
||||||
|
sessionManager: sessionManager,
|
||||||
|
websites: &models.WebsiteModel{DB: db},
|
||||||
|
users: &models.UserModel{DB: db, Settings: make(map[string]models.Setting)},
|
||||||
|
guestbookComments: &models.GuestbookCommentModel{DB: db},
|
||||||
|
formDecoder: formDecoder,
|
||||||
|
config: cfg,
|
||||||
|
debug: *debug,
|
||||||
|
timezones: getAvailableTimezones(),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = app.users.InitializeSettingsMap()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
err = app.websites.InitializeSettingsMap()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
if app.debug {
|
||||||
|
err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
|
||||||
|
} else {
|
||||||
|
err = srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
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 setupConfig(addr string) (applicationConfig, error) {
|
||||||
|
var c applicationConfig
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootUrl = os.Getenv("ROOT_URL")
|
||||||
|
oidcEnabled = os.Getenv("ENABLE_OIDC")
|
||||||
|
localLoginEnabled = os.Getenv("ENABLE_LOCAL_LOGIN")
|
||||||
|
oauth2Provider = os.Getenv("OAUTH2_PROVIDER")
|
||||||
|
clientID = os.Getenv("OAUTH2_CLIENT_ID")
|
||||||
|
clientSecret = os.Getenv("OAUTH2_CLIENT_SECRET")
|
||||||
|
environment = os.Getenv("ENVIRONMENT")
|
||||||
|
)
|
||||||
|
|
||||||
|
if environment != "" {
|
||||||
|
c.environment = environment
|
||||||
|
} else {
|
||||||
|
c.environment = "DEV"
|
||||||
|
}
|
||||||
|
|
||||||
|
if rootUrl != "" {
|
||||||
|
c.rootUrl = rootUrl
|
||||||
|
} else {
|
||||||
|
u, err := url.Parse(fmt.Sprintf("https://localhost%s", addr))
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
c.rootUrl = u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthEnabled, err := strconv.ParseBool(oidcEnabled)
|
||||||
|
if err != nil {
|
||||||
|
c.oauthEnabled = false
|
||||||
|
}
|
||||||
|
c.oauthEnabled = oauthEnabled
|
||||||
|
|
||||||
|
localAuthEnabled, err := strconv.ParseBool(localLoginEnabled)
|
||||||
|
if err != nil {
|
||||||
|
c.localAuthEnabled = true
|
||||||
|
}
|
||||||
|
c.localAuthEnabled = localAuthEnabled
|
||||||
|
|
||||||
|
if !c.oauthEnabled && !c.localAuthEnabled {
|
||||||
|
return c, errors.New("Either ENABLE_OIDC or ENABLE_LOCAL_LOGIN must be set to true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// if OIDC is disabled, no more configuration needs to be read
|
||||||
|
if !oauthEnabled {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var o applicationOauthConfig
|
||||||
|
if oauth2Provider == "" || clientID == "" || clientSecret == "" {
|
||||||
|
return c, errors.New("OAUTH2_PROVIDER, OAUTH2_CLIENT_ID, and OAUTH2_CLIENT_SECRET must be specified as environment variables.")
|
||||||
|
}
|
||||||
|
|
||||||
|
o.ctx = context.Background()
|
||||||
|
provider, err := oidc.NewProvider(o.ctx, oauth2Provider)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
o.provider = provider
|
||||||
|
o.oidcConfig = &oidc.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
}
|
||||||
|
o.verifier = provider.Verifier(o.oidcConfig)
|
||||||
|
o.config = &oauth2.Config{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
Endpoint: provider.Endpoint(),
|
||||||
|
RedirectURL: fmt.Sprintf("%s/users/login/oidc/callback", c.rootUrl),
|
||||||
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.oauth = o
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAvailableTimezones() []string {
|
||||||
|
var zones []string
|
||||||
|
var zoneDirs = []string{
|
||||||
|
"/usr/share/zoneinfo/",
|
||||||
|
"/usr/share/lib/zoneinfo/",
|
||||||
|
"/usr/lib/locale/TZ/",
|
||||||
|
}
|
||||||
|
for _, zd := range zoneDirs {
|
||||||
|
zones = walkTzDir(zd, zones)
|
||||||
|
for idx, zone := range zones {
|
||||||
|
zones[idx] = strings.ReplaceAll(zone, zd+"/", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zones
|
||||||
|
}
|
||||||
|
|
||||||
|
func walkTzDir(path string, zones []string) []string {
|
||||||
|
fileInfos, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return zones
|
||||||
|
}
|
||||||
|
isAlpha := func(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if !unicode.IsLetter(r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, info := range fileInfos {
|
||||||
|
if info.Name() != strings.ToUpper(info.Name()[:1])+info.Name()[1:] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isAlpha(info.Name()[:1]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newPath := path + "/" + info.Name()
|
||||||
|
if info.IsDir() {
|
||||||
|
zones = walkTzDir(newPath, zones)
|
||||||
|
} else {
|
||||||
|
zones = append(zones, newPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zones
|
||||||
|
|
||||||
|
}
|
101
cmd/web/middleware.go
Normal file
101
cmd/web/middleware.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
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 'self'; style-src-elem 'self';")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
user, err := app.users.GetById(id)
|
||||||
|
if err != nil {
|
||||||
|
app.serverError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
|
||||||
|
ctx = context.WithValue(ctx, userNameContextKey, user)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *application) enableCors(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
64
cmd/web/routes.go
Normal file
64
cmd/web/routes.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/ui"
|
||||||
|
"github.com/justinas/alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (app *application) routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
if app.config.environment == "PROD" {
|
||||||
|
mux.Handle("GET /static/", http.FileServerFS(ui.Files))
|
||||||
|
} else {
|
||||||
|
fileServer := http.FileServer(http.Dir("./ui/static/"))
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /ping", ping)
|
||||||
|
|
||||||
|
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
|
||||||
|
standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)
|
||||||
|
withCors := standard.Append(app.enableCors)
|
||||||
|
|
||||||
|
mux.Handle("/{$}", dynamic.ThenFunc(app.home))
|
||||||
|
mux.Handle("GET /websites/{id}/guestbook", dynamic.ThenFunc(app.getGuestbook))
|
||||||
|
mux.Handle("GET /websites/{id}/guestbook/comments", withCors.ThenFunc(app.getGuestbookCommentsSerialized))
|
||||||
|
mux.Handle("POST /websites/{id}/guestbook/comments/create/remote", standard.ThenFunc(app.postGuestbookCommentCreateRemote))
|
||||||
|
mux.Handle("GET /websites/{id}/guestbook/comments/create", dynamic.ThenFunc(app.getGuestbookCommentCreate))
|
||||||
|
mux.Handle("POST /websites/{id}/guestbook/comments/create", dynamic.ThenFunc(app.postGuestbookCommentCreate))
|
||||||
|
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))
|
||||||
|
mux.Handle("/users/login/oidc", dynamic.ThenFunc(app.userLoginOIDC))
|
||||||
|
mux.Handle("/users/login/oidc/callback", dynamic.ThenFunc(app.userLoginOIDCCallback))
|
||||||
|
mux.Handle("GET /help", dynamic.ThenFunc(app.notImplemented))
|
||||||
|
mux.Handle("GET /about", dynamic.ThenFunc(app.about))
|
||||||
|
|
||||||
|
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 /users/settings", protected.ThenFunc(app.getUserSettings))
|
||||||
|
mux.Handle("PUT /users/settings", protected.ThenFunc(app.putUserSettings))
|
||||||
|
mux.Handle("GET /guestbooks", protected.ThenFunc(app.getAllGuestbooks))
|
||||||
|
|
||||||
|
mux.Handle("GET /websites", protected.ThenFunc(app.getWebsiteList))
|
||||||
|
mux.Handle("GET /websites/create", protected.ThenFunc(app.getWebsiteCreate))
|
||||||
|
mux.Handle("POST /websites/create", protected.ThenFunc(app.postWebsiteCreate))
|
||||||
|
mux.Handle("GET /websites/{id}/dashboard", protected.ThenFunc(app.getWebsiteDashboard))
|
||||||
|
mux.Handle("GET /websites/{id}/dashboard/guestbook/comments", protected.ThenFunc(app.getGuestbookComments))
|
||||||
|
mux.Handle("GET /websites/{id}/dashboard/guestbook/comments/hidden", protected.ThenFunc(app.getCommentQueue))
|
||||||
|
mux.Handle("DELETE /websites/{id}/dashboard/guestbook/comments/{commentId}", protected.ThenFunc(app.deleteGuestbookComment))
|
||||||
|
mux.Handle("PUT /websites/{id}/dashboard/guestbook/comments/{commentId}", protected.ThenFunc(app.putHideGuestbookComment))
|
||||||
|
mux.Handle("GET /websites/{id}/dashboard/settings", protected.ThenFunc(app.getWebsiteSettings))
|
||||||
|
mux.Handle("PUT /websites/{id}/settings", protected.ThenFunc(app.putWebsiteSettings))
|
||||||
|
mux.Handle("PUT /websites/{id}", protected.ThenFunc(app.deleteWebsite))
|
||||||
|
mux.Handle("GET /websites/{id}/dashboard/guestbook/themes", protected.ThenFunc(app.getComingSoon))
|
||||||
|
mux.Handle("GET /websites/{id}/dashboard/guestbook/customize", protected.ThenFunc(app.getComingSoon))
|
||||||
|
|
||||||
|
return standard.Then(mux)
|
||||||
|
}
|
126
cmd/web/testutils_test.go
Normal file
126
cmd/web/testutils_test.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models/mocks"
|
||||||
|
"github.com/alexedwards/scs/v2"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||||
|
"github.com/gorilla/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestApplication(t *testing.T) *application {
|
||||||
|
formDecoder := schema.NewDecoder()
|
||||||
|
formDecoder.IgnoreUnknownKeys(true)
|
||||||
|
|
||||||
|
sessionManager := scs.New()
|
||||||
|
sessionManager.Lifetime = 12 * time.Hour
|
||||||
|
sessionManager.Cookie.Secure = true
|
||||||
|
|
||||||
|
return &application{
|
||||||
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
sessionManager: sessionManager,
|
||||||
|
websites: &mocks.WebsiteModel{},
|
||||||
|
users: &mocks.UserModel{},
|
||||||
|
guestbookComments: &mocks.GuestbookCommentModel{},
|
||||||
|
formDecoder: formDecoder,
|
||||||
|
timezones: getAvailableTimezones(),
|
||||||
|
config: applicationConfig{
|
||||||
|
localAuthEnabled: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestKey(t *testing.T) *rsa.PrivateKey {
|
||||||
|
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return priv
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestOIDCServer(t *testing.T, priv *rsa.PrivateKey) *testServer {
|
||||||
|
s := &oidctest.Server{
|
||||||
|
PublicKeys: []oidctest.PublicKey{
|
||||||
|
{
|
||||||
|
PublicKey: priv.Public(),
|
||||||
|
KeyID: "test-key",
|
||||||
|
Algorithm: oidc.ES256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ts := httptest.NewServer(s)
|
||||||
|
s.SetIssuer(ts.URL)
|
||||||
|
return &testServer{ts}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testServer struct {
|
||||||
|
*httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T, h http.Handler) *testServer {
|
||||||
|
ts := httptest.NewTLSServer(h)
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ts.Client().Jar = jar
|
||||||
|
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
return &testServer{ts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
|
||||||
|
rs, err := ts.Client().Get(ts.URL + urlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rs.Body.Close()
|
||||||
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body = bytes.TrimSpace(body)
|
||||||
|
|
||||||
|
return rs.StatusCode, rs.Header, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
|
||||||
|
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rs.Body.Close()
|
||||||
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body = bytes.TrimSpace(body)
|
||||||
|
return rs.StatusCode, rs.Header, string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var csrfTokenRX = regexp.MustCompile(`<input type="hidden" name="csrf_token" value="(.+?)">`)
|
||||||
|
|
||||||
|
func extractCSRFToken(t *testing.T, body string) string {
|
||||||
|
matches := csrfTokenRX.FindStringSubmatch(body)
|
||||||
|
if len(matches) < 2 {
|
||||||
|
t.Fatal("no csrf token found in body")
|
||||||
|
}
|
||||||
|
return html.UnescapeString(matches[1])
|
||||||
|
}
|
25
go.mod
Normal file
25
go.mod
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
module git.32bit.cafe/32bitcafe/guestbook
|
||||||
|
|
||||||
|
go 1.23.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.833
|
||||||
|
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c
|
||||||
|
github.com/alexedwards/scs/v2 v2.8.0
|
||||||
|
github.com/coreos/go-oidc/v3 v3.14.1
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.3
|
||||||
|
github.com/gorilla/schema v1.4.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/justinas/alice v1.2.0
|
||||||
|
github.com/justinas/nosurf v1.1.1
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
|
golang.org/x/crypto v0.36.0
|
||||||
|
golang.org/x/oauth2 v0.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
|
)
|
49
go.sum
Normal file
49
go.sum
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||||
|
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||||
|
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c h1:0gBCIsmH3+aaWK55APhhY7/Z+uv5IdbMqekI97V9shU=
|
||||||
|
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c/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/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
22
internal/assert/assert.go
Normal file
22
internal/assert/assert.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package assert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Equal[T comparable](t *testing.T, actual, expected T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if actual != expected {
|
||||||
|
t.Errorf("got: %v; want %v", actual, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StringContains(t *testing.T, actual, expectedSubstring string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if !strings.Contains(actual, expectedSubstring) {
|
||||||
|
t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring)
|
||||||
|
}
|
||||||
|
}
|
14
internal/auth/auth.go
Normal file
14
internal/auth/auth.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OAuth2ConfigInterface interface {
|
||||||
|
AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
|
||||||
|
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
|
||||||
|
Client(ctx context.Context, t *oauth2.Token) *http.Client
|
||||||
|
}
|
52
internal/forms/forms.go
Normal file
52
internal/forms/forms.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package forms
|
||||||
|
|
||||||
|
import "git.32bit.cafe/32bitcafe/guestbook/internal/validator"
|
||||||
|
|
||||||
|
type UserRegistrationForm struct {
|
||||||
|
Name string `schema:"username"`
|
||||||
|
Email string `schema:"email"`
|
||||||
|
Password string `schema:"password"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserLoginForm struct {
|
||||||
|
Email string `schema:"email"`
|
||||||
|
Password string `schema:"password"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommentCreateForm struct {
|
||||||
|
AuthorName string `schema:"authorname"`
|
||||||
|
AuthorEmail string `schema:"authoremail"`
|
||||||
|
AuthorSite string `schema:"authorsite"`
|
||||||
|
Content string `schema:"content"`
|
||||||
|
Redirect string `schema:"redirect"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteCreateForm struct {
|
||||||
|
Name string `schema:"sitename"`
|
||||||
|
SiteUrl string `schema:"siteurl"`
|
||||||
|
AuthorName string `schema:"authorname"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteDeleteForm struct {
|
||||||
|
Delete string `schema:"delete"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSettingsForm struct {
|
||||||
|
LocalTimezone string `schema:"timezones"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteSettingsForm struct {
|
||||||
|
SiteName string `schema:"ws_name"`
|
||||||
|
SiteUrl string `schema:"ws_url"`
|
||||||
|
AuthorName string `schema:"ws_author"`
|
||||||
|
Visibility string `schema:"gb_visible"`
|
||||||
|
CommentingEnabled string `schema:"gb_commenting"`
|
||||||
|
WidgetsEnabled string `schema:"gb_remote"`
|
||||||
|
validator.Validator `schema:"-"`
|
||||||
|
}
|
13
internal/models/errors.go
Normal file
13
internal/models/errors.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
|
ErrInvalidSettingValue = errors.New("models: invalid setting value")
|
||||||
|
)
|
200
internal/models/guestbookcomment.go
Normal file
200
internal/models/guestbookcomment.go
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
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
|
||||||
|
Deleted time.Time
|
||||||
|
IsPublished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestbookCommentSerialized struct {
|
||||||
|
AuthorName string
|
||||||
|
CommentText string
|
||||||
|
Created string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestbookCommentModel struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestbookCommentModelInterface interface {
|
||||||
|
Insert(shortId uint64, guestbookId, parentId int64, authorName, authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error)
|
||||||
|
Get(shortId uint64) (GuestbookComment, error)
|
||||||
|
GetVisible(guestbookId int64) ([]GuestbookComment, error)
|
||||||
|
GetVisibleSerialized(guestbookId int64) ([]GuestbookCommentSerialized, error)
|
||||||
|
GetDeleted(guestbookId int64) ([]GuestbookComment, error)
|
||||||
|
GetAll(guestbookId int64) ([]GuestbookComment, error)
|
||||||
|
UpdateComment(comment *GuestbookComment) error
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
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, Deleted FROM guestbook_comments WHERE ShortId = ?`
|
||||||
|
row := m.DB.QueryRow(stmt, shortId)
|
||||||
|
var c GuestbookComment
|
||||||
|
var t sql.NullTime
|
||||||
|
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, &t)
|
||||||
|
if err != nil {
|
||||||
|
return GuestbookComment{}, err
|
||||||
|
}
|
||||||
|
if t.Valid {
|
||||||
|
c.Deleted = t.Time
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetVisible(guestbookId int64) ([]GuestbookComment, error) {
|
||||||
|
stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
|
||||||
|
CommentText, PageUrl, Created, IsPublished
|
||||||
|
FROM guestbook_comments
|
||||||
|
WHERE GuestbookId = ? AND IsPublished = TRUE AND DELETED IS NULL
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetVisibleSerialized(guestbookId int64) ([]GuestbookCommentSerialized, error) {
|
||||||
|
stmt := `SELECT AuthorName, CommentText, Created
|
||||||
|
FROM guestbook_comments
|
||||||
|
WHERE GuestbookId = ? AND IsPublished = TRUE AND DELETED IS NULL
|
||||||
|
ORDER BY Created DESC`
|
||||||
|
rows, err := m.DB.Query(stmt, guestbookId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var comments []GuestbookCommentSerialized
|
||||||
|
for rows.Next() {
|
||||||
|
var c GuestbookCommentSerialized
|
||||||
|
err = rows.Scan(&c.AuthorName, &c.CommentText, &c.Created)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetDeleted(guestbookId int64) ([]GuestbookComment, error) {
|
||||||
|
stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
|
||||||
|
CommentText, PageUrl, Created, IsPublished, Deleted
|
||||||
|
FROM guestbook_comments
|
||||||
|
WHERE GuestbookId = ? AND Deleted IS NOT NULL
|
||||||
|
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
|
||||||
|
var t sql.NullTime
|
||||||
|
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, &t)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t.Valid {
|
||||||
|
c.Deleted = t.Time
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]GuestbookComment, error) {
|
||||||
|
stmt := `SELECT Id, ShortId, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite,
|
||||||
|
CommentText, PageUrl, Created, IsPublished
|
||||||
|
FROM guestbook_comments
|
||||||
|
WHERE GuestbookId = ? AND Deleted IS NULL
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
comments = append(comments, c)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return comments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) UpdateComment(comment *GuestbookComment) error {
|
||||||
|
stmt := `UPDATE guestbook_comments
|
||||||
|
SET CommentText = ?,
|
||||||
|
PageUrl = ?,
|
||||||
|
IsPublished = ?,
|
||||||
|
Deleted = ?
|
||||||
|
WHERE Id = ?`
|
||||||
|
var err error
|
||||||
|
if comment.Deleted.IsZero() {
|
||||||
|
_, err = m.DB.Exec(stmt, comment.CommentText, comment.PageUrl, comment.IsPublished, nil, comment.ID)
|
||||||
|
} else {
|
||||||
|
_, err = m.DB.Exec(stmt, comment.CommentText, comment.PageUrl, comment.IsPublished, comment.Deleted, comment.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
81
internal/models/mocks/guestbookcomment.go
Normal file
81
internal/models/mocks/guestbookcomment.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mockGuestbookComment = models.GuestbookComment{
|
||||||
|
ID: 1,
|
||||||
|
ShortId: 1,
|
||||||
|
GuestbookId: 1,
|
||||||
|
AuthorName: "John Test",
|
||||||
|
AuthorEmail: "test@example.com",
|
||||||
|
AuthorSite: "example.com",
|
||||||
|
CommentText: "Hello, world",
|
||||||
|
Created: time.Now(),
|
||||||
|
IsPublished: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var mockSerializedGuestbookComment = models.GuestbookCommentSerialized{
|
||||||
|
AuthorName: "John Test",
|
||||||
|
CommentText: "Hello, world",
|
||||||
|
Created: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestbookCommentModel struct{}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) Insert(shortId uint64, guestbookId, parentId int64, authorName,
|
||||||
|
authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (int64, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) Get(shortId uint64) (models.GuestbookComment, error) {
|
||||||
|
switch shortId {
|
||||||
|
case 1:
|
||||||
|
return mockGuestbookComment, nil
|
||||||
|
default:
|
||||||
|
return models.GuestbookComment{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetVisible(guestbookId int64) ([]models.GuestbookComment, error) {
|
||||||
|
switch guestbookId {
|
||||||
|
case 1:
|
||||||
|
return []models.GuestbookComment{mockGuestbookComment}, nil
|
||||||
|
case 2:
|
||||||
|
return []models.GuestbookComment{}, nil
|
||||||
|
default:
|
||||||
|
return []models.GuestbookComment{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetVisibleSerialized(guestbookId int64) ([]models.GuestbookCommentSerialized, error) {
|
||||||
|
switch guestbookId {
|
||||||
|
case 1:
|
||||||
|
return []models.GuestbookCommentSerialized{mockSerializedGuestbookComment}, nil
|
||||||
|
case 2:
|
||||||
|
return []models.GuestbookCommentSerialized{}, nil
|
||||||
|
default:
|
||||||
|
return []models.GuestbookCommentSerialized{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetDeleted(guestbookId int64) ([]models.GuestbookComment, error) {
|
||||||
|
switch guestbookId {
|
||||||
|
default:
|
||||||
|
return []models.GuestbookComment{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) GetAll(guestbookId int64) ([]models.GuestbookComment, error) {
|
||||||
|
switch guestbookId {
|
||||||
|
default:
|
||||||
|
return []models.GuestbookComment{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *GuestbookCommentModel) UpdateComment(comment *models.GuestbookComment) error {
|
||||||
|
return nil
|
||||||
|
}
|
122
internal/models/mocks/user.go
Normal file
122
internal/models/mocks/user.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mockUser = models.User{
|
||||||
|
ID: 1,
|
||||||
|
ShortId: 1,
|
||||||
|
Username: "tester",
|
||||||
|
Email: "test@example.com",
|
||||||
|
Deleted: false,
|
||||||
|
IsBanned: false,
|
||||||
|
Created: time.Now(),
|
||||||
|
Settings: mockUserSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
var mockUserSettings = models.UserSettings{
|
||||||
|
LocalTimezone: time.UTC,
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModel struct {
|
||||||
|
Settings map[string]models.Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) InitializeSettingsMap() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Insert(shortId uint64, username string, email string, password string, settings models.UserSettings) error {
|
||||||
|
switch email {
|
||||||
|
case "dupe@example.com":
|
||||||
|
return models.ErrDuplicateEmail
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, password string, settings models.UserSettings) (int64, error) {
|
||||||
|
switch email {
|
||||||
|
case "dupe@example.com":
|
||||||
|
return -1, models.ErrDuplicateEmail
|
||||||
|
default:
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Get(shortId uint64) (models.User, error) {
|
||||||
|
switch shortId {
|
||||||
|
case 1:
|
||||||
|
return mockUser, nil
|
||||||
|
default:
|
||||||
|
return models.User{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetById(id int64) (models.User, error) {
|
||||||
|
switch id {
|
||||||
|
case 1:
|
||||||
|
return mockUser, nil
|
||||||
|
default:
|
||||||
|
return models.User{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetAll() ([]models.User, error) {
|
||||||
|
return []models.User{mockUser}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Authenticate(email, password string) (int64, error) {
|
||||||
|
if email == "test@example.com" && password == "password" {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
return 0, models.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Exists(id int64) (bool, error) {
|
||||||
|
switch id {
|
||||||
|
case 1:
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetSettings(userId int64) (models.UserSettings, error) {
|
||||||
|
return mockUserSettings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateUserSettings(userId int64, settings models.UserSettings) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSetting(userId int64, setting models.Setting, value string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetBySubject(subject string) (int64, error) {
|
||||||
|
if subject == "goodSubject" {
|
||||||
|
return 1, nil
|
||||||
|
} else if subject == "foo" {
|
||||||
|
return -1, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
return -1, errors.New("Unexpected Error")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetByEmail(email string) (int64, error) {
|
||||||
|
if email == "test@example.com" {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
return -1, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSubject(userId int64, subject string) error {
|
||||||
|
if userId == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("invalid")
|
||||||
|
}
|
89
internal/models/mocks/website.go
Normal file
89
internal/models/mocks/website.go
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mockGuestbook = models.Guestbook{
|
||||||
|
ID: 1,
|
||||||
|
ShortId: 1,
|
||||||
|
UserId: 1,
|
||||||
|
WebsiteId: 1,
|
||||||
|
Created: time.Now(),
|
||||||
|
IsActive: true,
|
||||||
|
Settings: models.GuestbookSettings{
|
||||||
|
IsCommentingEnabled: true,
|
||||||
|
IsVisible: true,
|
||||||
|
FilteredWords: make([]string, 0),
|
||||||
|
AllowRemoteHostAccess: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var mockWebsite = models.Website{
|
||||||
|
ID: 1,
|
||||||
|
ShortId: 1,
|
||||||
|
Name: "Example",
|
||||||
|
// SiteUrl: "example.com",
|
||||||
|
Url: &url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
AuthorName: "John Test",
|
||||||
|
UserId: 1,
|
||||||
|
Created: time.Now(),
|
||||||
|
Guestbook: mockGuestbook,
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteModel struct{}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Get(shortId uint64) (models.Website, error) {
|
||||||
|
switch shortId {
|
||||||
|
case 1:
|
||||||
|
return mockWebsite, nil
|
||||||
|
default:
|
||||||
|
return models.Website{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) GetAllUser(userId int64) ([]models.Website, error) {
|
||||||
|
return []models.Website{mockWebsite}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) GetById(id int64) (models.Website, error) {
|
||||||
|
switch id {
|
||||||
|
case 1:
|
||||||
|
return mockWebsite, nil
|
||||||
|
default:
|
||||||
|
return models.Website{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) GetAll() ([]models.Website, error) {
|
||||||
|
return []models.Website{mockWebsite}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Update(w models.Website) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *WebsiteModel) Delete(websiteId int64) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) InitializeSettingsMap() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) UpdateGuestbookSettings(guestbookId int64, settings models.GuestbookSettings) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) UpdateSetting(guestbookId int64, setting models.Setting, value string) error {
|
||||||
|
return nil
|
||||||
|
}
|
165
internal/models/settings.go
Normal file
165
internal/models/settings.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingGroup struct {
|
||||||
|
id int
|
||||||
|
description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *SettingGroup) Id() int {
|
||||||
|
return g.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *SettingGroup) Description() string {
|
||||||
|
return g.description
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SETTING_GROUP_USER = "user"
|
||||||
|
SETTING_GROUP_GUESTBOOK = "guestbook"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingDataType struct {
|
||||||
|
id int
|
||||||
|
description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *SettingDataType) Id() int {
|
||||||
|
return d.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *SettingDataType) Description() string {
|
||||||
|
return d.description
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SETTING_TYPE_INTEGER = "integer"
|
||||||
|
SETTING_TYPE_STRING = "alphanumeric"
|
||||||
|
SETTING_TYPE_DATE = "datetime"
|
||||||
|
SETTING_TYPE_BOOL = "boolean"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Setting struct {
|
||||||
|
id int
|
||||||
|
description string
|
||||||
|
constrained bool
|
||||||
|
dataType SettingDataType
|
||||||
|
settingGroup SettingGroup
|
||||||
|
minValue string
|
||||||
|
maxValue string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) Id() int {
|
||||||
|
return s.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) Description() string {
|
||||||
|
return s.description
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) Constrained() bool {
|
||||||
|
return s.constrained
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) DataType() SettingDataType {
|
||||||
|
return s.dataType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) SettingGroup() SettingGroup {
|
||||||
|
return s.settingGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) MinValue() string {
|
||||||
|
return s.minValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) MaxValue() string {
|
||||||
|
return s.maxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) Validate(value string) bool {
|
||||||
|
switch s.dataType.description {
|
||||||
|
case SETTING_TYPE_INTEGER:
|
||||||
|
return s.validateInt(value)
|
||||||
|
case SETTING_TYPE_STRING:
|
||||||
|
return s.validateAlphanum(value)
|
||||||
|
case SETTING_TYPE_DATE:
|
||||||
|
return s.validateDatetime(value)
|
||||||
|
case SETTING_TYPE_BOOL:
|
||||||
|
return s.validateBool(value)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) validateInt(value string) bool {
|
||||||
|
v, err := strconv.ParseInt(value, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var min int64
|
||||||
|
var max int64
|
||||||
|
if len(s.minValue) > 0 {
|
||||||
|
min, err = strconv.ParseInt(s.minValue, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v < min {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.maxValue) > 0 {
|
||||||
|
max, err = strconv.ParseInt(s.maxValue, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v < max {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) validateDatetime(value string) bool {
|
||||||
|
v, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var min time.Time
|
||||||
|
var max time.Time
|
||||||
|
|
||||||
|
if len(s.minValue) > 0 {
|
||||||
|
min, err = time.Parse(time.RFC3339, s.minValue)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.Before(min) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.maxValue) > 0 {
|
||||||
|
max, err = time.Parse(time.RFC3339, s.maxValue)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if v.After(max) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) validateAlphanum(value string) bool {
|
||||||
|
return len(value) >= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) validateBool(value string) bool {
|
||||||
|
_, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
402
internal/models/user.go
Normal file
402
internal/models/user.go
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattn/go-sqlite3"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserSettings struct {
|
||||||
|
LocalTimezone *time.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingUserTimezone = "local_timezone"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int64
|
||||||
|
ShortId uint64
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
Deleted bool
|
||||||
|
IsBanned bool
|
||||||
|
HashedPassword []byte
|
||||||
|
Created time.Time
|
||||||
|
Settings UserSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModel struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Settings map[string]Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserIdToken struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Username string `json:"preferred_username"`
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserModelInterface interface {
|
||||||
|
InitializeSettingsMap() error
|
||||||
|
Insert(shortId uint64, username string, email string, password string, settings UserSettings) error
|
||||||
|
InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error)
|
||||||
|
Get(shortId uint64) (User, error)
|
||||||
|
GetById(id int64) (User, error)
|
||||||
|
GetByEmail(email string) (int64, error)
|
||||||
|
GetBySubject(subject string) (int64, error)
|
||||||
|
GetAll() ([]User, error)
|
||||||
|
Authenticate(email, password string) (int64, error)
|
||||||
|
Exists(id int64) (bool, error)
|
||||||
|
UpdateUserSettings(userId int64, settings UserSettings) error
|
||||||
|
UpdateSetting(userId int64, setting Setting, value string) error
|
||||||
|
UpdateSubject(userId int64, subject string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) InitializeSettingsMap() error {
|
||||||
|
if m.Settings == nil {
|
||||||
|
m.Settings = make(map[string]Setting)
|
||||||
|
}
|
||||||
|
stmt := `SELECT settings.Id, settings.Description, Constrained, d.Id, d.Description, g.Id, g.Description, MinValue, MaxValue
|
||||||
|
FROM settings
|
||||||
|
LEFT JOIN setting_data_types d ON settings.DataType = d.Id
|
||||||
|
LEFT JOIN setting_groups g ON settings.SettingGroup = g.Id
|
||||||
|
WHERE SettingGroup = (SELECT Id FROM setting_groups WHERE Description = 'user' LIMIT 1)`
|
||||||
|
result, err := m.DB.Query(stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for result.Next() {
|
||||||
|
var s Setting
|
||||||
|
var mn sql.NullString
|
||||||
|
var mx sql.NullString
|
||||||
|
err := result.Scan(&s.id, &s.description, &s.constrained, &s.dataType.id, &s.dataType.description, &s.settingGroup.id, &s.settingGroup.description, &mn, &mx)
|
||||||
|
if mn.Valid {
|
||||||
|
s.minValue = mn.String
|
||||||
|
}
|
||||||
|
if mx.Valid {
|
||||||
|
s.maxValue = mx.String
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Settings[s.description] = s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Insert(shortId uint64, username string, email string, password string, settings UserSettings) error {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt := `INSERT INTO users (ShortId, Username, Email, IsBanned, HashedPassword, Created)
|
||||||
|
VALUES (?, ?, ?, FALSE, ?, ?)`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := tx.Exec(stmt, shortId, username, email, hashedPassword, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sqliteError, ok := err.(sqlite3.Error); ok {
|
||||||
|
if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") {
|
||||||
|
return ErrDuplicateEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt = `INSERT INTO user_settings (UserId, SettingId, AllowedSettingValueId, UnconstrainedValue)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
_, err = tx.Exec(stmt, id, m.Settings[SettingUserTimezone].id, nil, settings.LocalTimezone.String())
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error) {
|
||||||
|
stmt := `INSERT INTO users (ShortId, Username, Email, IsBanned, OIDCSubject, Created)
|
||||||
|
VALUES (?, ?, ?, FALSE, ?, ?)`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
result, err := tx.Exec(stmt, shortId, username, email, subject, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
if sqliteError, ok := err.(sqlite3.Error); ok {
|
||||||
|
if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") {
|
||||||
|
return -1, ErrDuplicateEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
stmt = `INSERT INTO user_settings (UserId, SettingId, AllowedSettingValueId, UnconstrainedValue)
|
||||||
|
VALUES (?, ?, ?, ?)`
|
||||||
|
_, err = tx.Exec(stmt, id, m.Settings[SettingUserTimezone].id, nil, settings.LocalTimezone.String())
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Get(shortId uint64) (User, error) {
|
||||||
|
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND Deleted IS NULL`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(stmt, shortId)
|
||||||
|
var u User
|
||||||
|
err = row.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created)
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return User{}, ErrNoRecord
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
settings, err := m.getSettings(tx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
u.Settings = settings
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
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 Deleted IS NULL`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(stmt, id)
|
||||||
|
var u User
|
||||||
|
err = row.Scan(&u.ID, &u.ShortId, &u.Username, &u.Email, &u.Created)
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return User{}, ErrNoRecord
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
settings, err := m.getSettings(tx, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
u.Settings = settings
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetAll() ([]User, error) {
|
||||||
|
stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE DELETED IS NULL`
|
||||||
|
rows, err := m.DB.Query(stmt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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) GetByEmail(email string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
stmt := `SELECT Id FROM users WHERE Email = ?`
|
||||||
|
err := m.DB.QueryRow(stmt, email).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return -1, ErrNoRecord
|
||||||
|
} else {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) GetBySubject(subject string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
var s sql.NullString
|
||||||
|
stmt := `SELECT Id, OIDCSubject FROM users WHERE OIDCSubject = ?`
|
||||||
|
err := m.DB.QueryRow(stmt, subject).Scan(&id, &s)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return -1, ErrNoRecord
|
||||||
|
} else {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSubject(userId int64, subject string) error {
|
||||||
|
stmt := `UPDATE users SET OIDCSubject = ? WHERE Id = ?`
|
||||||
|
_, err := m.DB.Exec(stmt, subject, userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Exists(id int64) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND DELETED IS NULL)`
|
||||||
|
err := m.DB.QueryRow(stmt, id).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) getSettings(tx *sql.Tx, userId int64) (UserSettings, error) {
|
||||||
|
stmt := `SELECT u.SettingId, a.ItemValue, u.UnconstrainedValue FROM user_settings AS u
|
||||||
|
LEFT JOIN allowed_setting_values AS a ON u.AllowedSettingValueId = a.Id
|
||||||
|
WHERE UserId = ?`
|
||||||
|
var settings UserSettings
|
||||||
|
rows, err := tx.Query(stmt, userId)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var itemValue sql.NullString
|
||||||
|
var unconstrainedValue sql.NullString
|
||||||
|
err = rows.Scan(&id, &itemValue, &unconstrainedValue)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
switch id {
|
||||||
|
case m.Settings[SettingUserTimezone].id:
|
||||||
|
settings.LocalTimezone, err = time.LoadLocation(unconstrainedValue.String)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateUserSettings(userId int64, settings UserSettings) error {
|
||||||
|
err := m.UpdateSetting(userId, m.Settings[SettingUserTimezone], settings.LocalTimezone.String())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) UpdateSetting(userId int64, setting Setting, value string) error {
|
||||||
|
valid := setting.Validate(value)
|
||||||
|
if !valid {
|
||||||
|
return ErrInvalidSettingValue
|
||||||
|
}
|
||||||
|
stmt := `UPDATE user_settings SET
|
||||||
|
AllowedSettingValueId=IFNULL(
|
||||||
|
(SELECT Id FROM allowed_setting_values WHERE SettingId = user_settings.SettingId AND ItemValue = ?), AllowedSettingValueId
|
||||||
|
),
|
||||||
|
UnconstrainedValue=(SELECT ? FROM settings WHERE settings.Id = user_settings.SettingId AND settings.Constrained=0)
|
||||||
|
WHERE userId = ?
|
||||||
|
AND SettingId = (SELECT Id from Settings WHERE Description=?);`
|
||||||
|
result, err := m.DB.Exec(stmt, value, value, userId, setting.description)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
return ErrInvalidSettingValue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
433
internal/models/website.go
Normal file
433
internal/models/website.go
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Website struct {
|
||||||
|
ID int64
|
||||||
|
ShortId uint64
|
||||||
|
Name string
|
||||||
|
Url *url.URL
|
||||||
|
AuthorName string
|
||||||
|
UserId int64
|
||||||
|
Created time.Time
|
||||||
|
Deleted time.Time
|
||||||
|
Guestbook Guestbook
|
||||||
|
}
|
||||||
|
|
||||||
|
type GuestbookSettings struct {
|
||||||
|
IsCommentingEnabled bool
|
||||||
|
ReenableCommenting time.Time
|
||||||
|
IsVisible bool
|
||||||
|
FilteredWords []string
|
||||||
|
AllowRemoteHostAccess bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var ValidDisableDurations = []string{"true", "false", "1h", "4h", "8h", "24h", "72h", "168h"}
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingGbCommentingEnabled = "commenting_enabled"
|
||||||
|
SettingGbReenableComments = "reenable_comments"
|
||||||
|
SettingGbVisible = "is_visible"
|
||||||
|
SettingGbFilteredWords = "filtered_words"
|
||||||
|
SettingGbAllowRemote = "remote_enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Guestbook struct {
|
||||||
|
ID int64
|
||||||
|
ShortId uint64
|
||||||
|
UserId int64
|
||||||
|
WebsiteId int64
|
||||||
|
Created time.Time
|
||||||
|
Deleted time.Time
|
||||||
|
IsActive bool
|
||||||
|
Settings GuestbookSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g Guestbook) CanComment() bool {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return g.Settings.IsCommentingEnabled && g.Settings.ReenableCommenting.Before(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteModel struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Settings map[string]Setting
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) InitializeSettingsMap() error {
|
||||||
|
if m.Settings == nil {
|
||||||
|
m.Settings = make(map[string]Setting)
|
||||||
|
}
|
||||||
|
stmt := `SELECT settings.Id, settings.Description, Constrained, d.Id, d.Description, g.Id, g.Description, MinValue, MaxValue
|
||||||
|
FROM settings
|
||||||
|
LEFT JOIN setting_data_types d ON settings.DataType = d.Id
|
||||||
|
LEFT JOIN setting_groups g ON settings.SettingGroup = g.Id
|
||||||
|
WHERE SettingGroup = (SELECT Id FROM setting_groups WHERE Description = 'guestbook' LIMIT 1)`
|
||||||
|
result, err := m.DB.Query(stmt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for result.Next() {
|
||||||
|
var s Setting
|
||||||
|
var mn sql.NullString
|
||||||
|
var mx sql.NullString
|
||||||
|
err := result.Scan(&s.id, &s.description, &s.constrained, &s.dataType.id, &s.dataType.description, &s.settingGroup.id, &s.settingGroup.description, &mn, &mx)
|
||||||
|
if mn.Valid {
|
||||||
|
s.minValue = mn.String
|
||||||
|
}
|
||||||
|
if mx.Valid {
|
||||||
|
s.maxValue = mx.String
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Settings[s.description] = s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebsiteModelInterface interface {
|
||||||
|
Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error)
|
||||||
|
Get(shortId uint64) (Website, error)
|
||||||
|
GetAllUser(userId int64) ([]Website, error)
|
||||||
|
GetAll() ([]Website, error)
|
||||||
|
Update(w Website) error
|
||||||
|
InitializeSettingsMap() error
|
||||||
|
UpdateGuestbookSettings(guestbookId int64, settings GuestbookSettings) error
|
||||||
|
UpdateSetting(guestbookId int64, setting Setting, value string) error
|
||||||
|
Delete(websiteId int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Insert(shortId uint64, userId int64, siteName, siteUrl, authorName string) (int64, error) {
|
||||||
|
stmt := `INSERT INTO websites (ShortId, Name, SiteUrl, AuthorName, UserId, Created)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := tx.Exec(stmt, shortId, siteName, siteUrl, authorName, userId, time.Now().UTC())
|
||||||
|
// result, err := m.DB.Exec(stmt, shortId, siteName, siteUrl, authorName, userId, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
if rollbackError := tx.Rollback(); rollbackError != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
websiteId, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
if rollbackError := tx.Rollback(); rollbackError != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt = `INSERT INTO guestbooks (ShortId, UserId, WebsiteId, Created, IsActive)
|
||||||
|
VALUES(?, ?, ?, ?, TRUE)`
|
||||||
|
result, err = tx.Exec(stmt, shortId, userId, websiteId, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
if rollbackError := tx.Rollback(); rollbackError != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
guestbookId, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
if rollbackError := tx.Rollback(); rollbackError != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := GuestbookSettings{
|
||||||
|
IsCommentingEnabled: true,
|
||||||
|
IsVisible: true,
|
||||||
|
AllowRemoteHostAccess: true,
|
||||||
|
}
|
||||||
|
stmt = `INSERT INTO guestbook_settings (GuestbookId, SettingId, AllowedSettingValueId, UnconstrainedValue) VALUES
|
||||||
|
(?, ?, ?, ?),
|
||||||
|
(?, ?, ?, ?),
|
||||||
|
(?, ?, ?, ?),
|
||||||
|
(?, ?, ?, ?)`
|
||||||
|
_, err = tx.Exec(stmt,
|
||||||
|
guestbookId, m.Settings[SettingGbCommentingEnabled].id, settings.IsCommentingEnabled, nil,
|
||||||
|
guestbookId, m.Settings[SettingGbReenableComments].id, nil, settings.ReenableCommenting.Format(time.RFC3339),
|
||||||
|
guestbookId, m.Settings[SettingGbVisible].id, settings.IsVisible, nil,
|
||||||
|
guestbookId, m.Settings[SettingGbAllowRemote].id, settings.AllowRemoteHostAccess, nil)
|
||||||
|
if err != nil {
|
||||||
|
if rollbackError := tx.Rollback(); rollbackError != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
return websiteId, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Get(shortId uint64) (Website, error) {
|
||||||
|
stmt := `SELECT w.Id, w.ShortId, w.Name, w.SiteUrl, w.AuthorName, w.UserId, w.Created
|
||||||
|
FROM websites AS w
|
||||||
|
WHERE w.ShortId = ? AND w.DELETED IS NULL`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return Website{}, nil
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(stmt, shortId)
|
||||||
|
var w Website
|
||||||
|
var u string
|
||||||
|
err = row.Scan(&w.ID, &w.ShortId, &w.Name, &u, &w.AuthorName, &w.UserId, &w.Created)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
err = ErrNoRecord
|
||||||
|
}
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
w.Url, err = url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt = `SELECT Id, ShortId, UserId, WebsiteId, Created, IsActive FROM guestbooks
|
||||||
|
WHERE WebsiteId = ? AND Deleted IS NULL`
|
||||||
|
row = tx.QueryRow(stmt, w.ID)
|
||||||
|
var g Guestbook
|
||||||
|
err = row.Scan(&g.ID, &g.ShortId, &g.UserId, &g.WebsiteId, &g.Created, &g.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
err = ErrNoRecord
|
||||||
|
}
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
gbSettings, err := m.getGuestbookSettings(tx, g.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
err = ErrNoRecord
|
||||||
|
}
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
return Website{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return Website{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// if comment disable setting has expired, enable commenting
|
||||||
|
commentingReenabled := time.Now().UTC().After(gbSettings.ReenableCommenting)
|
||||||
|
if commentingReenabled {
|
||||||
|
gbSettings.IsCommentingEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Settings = gbSettings
|
||||||
|
w.Guestbook = g
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) GetAllUser(userId int64) ([]Website, error) {
|
||||||
|
stmt := `SELECT w.Id, w.ShortId, w.Name, w.SiteUrl, w.AuthorName, w.UserId, w.Created,
|
||||||
|
g.Id, g.ShortId, g.Created, g.IsActive
|
||||||
|
FROM websites AS w INNER JOIN guestbooks AS g ON w.Id = g.WebsiteId
|
||||||
|
WHERE w.UserId = ? AND w.Deleted IS NULL`
|
||||||
|
rows, err := m.DB.Query(stmt, userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var websites []Website
|
||||||
|
for rows.Next() {
|
||||||
|
var w Website
|
||||||
|
var u string
|
||||||
|
err := rows.Scan(&w.ID, &w.ShortId, &w.Name, &u, &w.AuthorName, &w.UserId, &w.Created,
|
||||||
|
&w.Guestbook.ID, &w.Guestbook.ShortId, &w.Guestbook.Created, &w.Guestbook.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.Url, err = url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
websites = append(websites, w)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return websites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) GetAll() ([]Website, error) {
|
||||||
|
stmt := `SELECT w.Id, w.ShortId, w.Name, w.SiteUrl, w.AuthorName, w.UserId, w.Created,
|
||||||
|
g.Id, g.ShortId, g.Created, g.IsActive
|
||||||
|
FROM websites AS w INNER JOIN guestbooks AS g ON w.Id = g.WebsiteId WHERE w.Deleted IS NULL`
|
||||||
|
rows, err := m.DB.Query(stmt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var websites []Website
|
||||||
|
for rows.Next() {
|
||||||
|
var w Website
|
||||||
|
var u string
|
||||||
|
err := rows.Scan(&w.ID, &w.ShortId, &w.Name, &u, &w.AuthorName, &w.UserId, &w.Created,
|
||||||
|
&w.Guestbook.ID, &w.Guestbook.ShortId, &w.Guestbook.Created, &w.Guestbook.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w.Url, err = url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
websites = append(websites, w)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return websites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Update(w Website) error {
|
||||||
|
stmt := `UPDATE websites SET Name = ?, SiteUrl = ?, AuthorName = ? WHERE ID = ?`
|
||||||
|
r, err := m.DB.Exec(stmt, w.Name, w.Url.String(), w.AuthorName, w.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, err := r.RowsAffected(); rows != 1 {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errors.New("Failed to update website")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) Delete(websiteId int64) error {
|
||||||
|
stmt := `UPDATE websites SET Deleted = ? WHERE ID = ?`
|
||||||
|
tx, err := m.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := time.Now().UTC()
|
||||||
|
_, err = tx.Exec(stmt, t, websiteId)
|
||||||
|
if err != nil {
|
||||||
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
|
return rbErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stmt = `UPDATE guestbooks SET Deleted = ? WHERE WebsiteId = ?`
|
||||||
|
_, err = tx.Exec(stmt, t, websiteId)
|
||||||
|
if err != nil {
|
||||||
|
if rbErr := tx.Rollback(); rbErr != nil {
|
||||||
|
return rbErr
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) getGuestbookSettings(tx *sql.Tx, guestbookId int64) (GuestbookSettings, error) {
|
||||||
|
stmt := `SELECT g.SettingId, a.ItemValue, g.UnconstrainedValue FROM guestbook_settings AS g
|
||||||
|
LEFT JOIN allowed_setting_values AS a ON g.AllowedSettingValueId = a.Id
|
||||||
|
WHERE GuestbookId = ?`
|
||||||
|
var settings GuestbookSettings
|
||||||
|
rows, err := tx.Query(stmt, guestbookId)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var itemValue sql.NullString
|
||||||
|
var unconstrainedValue sql.NullString
|
||||||
|
err = rows.Scan(&id, &itemValue, &unconstrainedValue)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
switch id {
|
||||||
|
case m.Settings[SettingGbCommentingEnabled].id:
|
||||||
|
settings.IsCommentingEnabled, err = strconv.ParseBool(itemValue.String)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case m.Settings[SettingGbReenableComments].id:
|
||||||
|
settings.ReenableCommenting, err = time.Parse(time.RFC3339, unconstrainedValue.String)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case m.Settings[SettingGbVisible].id:
|
||||||
|
settings.IsVisible, err = strconv.ParseBool(itemValue.String)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case m.Settings[SettingGbAllowRemote].id:
|
||||||
|
settings.AllowRemoteHostAccess, err = strconv.ParseBool(itemValue.String)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) UpdateGuestbookSettings(guestbookId int64, settings GuestbookSettings) error {
|
||||||
|
err := m.UpdateSetting(guestbookId, m.Settings[SettingGbVisible], strconv.FormatBool(settings.IsVisible))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = m.UpdateSetting(guestbookId, m.Settings[SettingGbAllowRemote], strconv.FormatBool(settings.AllowRemoteHostAccess))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = m.UpdateSetting(guestbookId, m.Settings[SettingGbCommentingEnabled], strconv.FormatBool(settings.IsCommentingEnabled))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = m.UpdateSetting(guestbookId, m.Settings[SettingGbReenableComments], settings.ReenableCommenting.Format(time.RFC3339))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *WebsiteModel) UpdateSetting(guestbookId int64, setting Setting, value string) error {
|
||||||
|
stmt := `UPDATE guestbook_settings SET
|
||||||
|
AllowedSettingValueId=IFNULL(
|
||||||
|
(SELECT Id FROM allowed_setting_values WHERE SettingId = guestbook_settings.SettingId AND ItemValue = ?), AllowedSettingValueId
|
||||||
|
),
|
||||||
|
UnconstrainedValue=(SELECT ? FROM settings WHERE settings.Id = guestbook_settings.SettingId AND settings.Constrained=0)
|
||||||
|
WHERE GuestbookId = ?
|
||||||
|
AND SettingId = (SELECT Id from Settings WHERE Description=?);`
|
||||||
|
result, err := m.DB.Exec(stmt, value, value, guestbookId, setting.description)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
return ErrInvalidSettingValue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
63
internal/validator/validator.go
Normal file
63
internal/validator/validator.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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])?)*$")
|
||||||
|
var WebRX = regexp.MustCompile("^https?:\\/\\/")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Equals(expected, actual string) bool {
|
||||||
|
return expected == actual
|
||||||
|
}
|
2
migrations/000001_create_users_table.down.sql
Normal file
2
migrations/000001_create_users_table.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE users;
|
||||||
|
DROP TABLE sessions;
|
16
migrations/000001_create_users_table.up.sql
Normal file
16
migrations/000001_create_users_table.up.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
ShortId integer UNIQUE NOT NULL,
|
||||||
|
Username varchar(32) NOT NULL,
|
||||||
|
Email varchar(256) UNIQUE NOT NULL,
|
||||||
|
Deleted datetime,
|
||||||
|
IsBanned boolean NOT NULL DEFAULT FALSE,
|
||||||
|
HashedPassword char(60) NOT NULL,
|
||||||
|
Created datetime NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token CHAR(43) primary key,
|
||||||
|
data BLOB NOT NULL,
|
||||||
|
expiry TEXT NOT NULL
|
||||||
|
);
|
3
migrations/000002_create_websites_table.down.sql
Normal file
3
migrations/000002_create_websites_table.down.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS guestbook_comments;
|
||||||
|
DROP TABLE IF EXISTS guestbooks;
|
||||||
|
DROP TABLE IF EXISTS websites;
|
52
migrations/000002_create_websites_table.up.sql
Normal file
52
migrations/000002_create_websites_table.up.sql
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE websites (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
ShortId integer UNIQUE NOT NULL,
|
||||||
|
Name varchar(256) NOT NULL,
|
||||||
|
SiteUrl varchar(512) NOT NULL,
|
||||||
|
AuthorName varchar(512) NOT NULL,
|
||||||
|
UserId integer NOT NULL,
|
||||||
|
Created datetime NOT NULL,
|
||||||
|
Deleted datetime,
|
||||||
|
FOREIGN KEY (UserId) REFERENCES users(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE guestbooks (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
ShortId integer UNIQUE NOT NULL,
|
||||||
|
WebsiteId integer UNIQUE NOT NULL,
|
||||||
|
UserId integer NOT NULL,
|
||||||
|
Created datetime NOT NULL,
|
||||||
|
Deleted datetime,
|
||||||
|
IsActive boolean NOT NULL DEFAULT TRUE,
|
||||||
|
FOREIGN KEY (UserId) REFERENCES users(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
FOREIGN KEY (WebsiteId) REFERENCES websites(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE guestbook_comments (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
ShortId integer UNIQUE NOT NULL,
|
||||||
|
GuestbookId integer NOT NULL,
|
||||||
|
ParentId integer,
|
||||||
|
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,
|
||||||
|
Deleted datetime,
|
||||||
|
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
|
||||||
|
);
|
6
migrations/000003_create_settings_tables.down.sql
Normal file
6
migrations/000003_create_settings_tables.down.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
DROP TABLE IF EXISTS guestbook_settings;
|
||||||
|
DROP TABLE IF EXISTS user_settings;
|
||||||
|
DROP TABLE IF EXISTS allowed_setting_values;
|
||||||
|
DROP TABLE IF EXISTS settings;
|
||||||
|
DROP TABLE IF EXISTS setting_data_types;
|
||||||
|
DROP TABLE IF EXISTS setting_groups;
|
70
migrations/000003_create_settings_tables.up.sql
Normal file
70
migrations/000003_create_settings_tables.up.sql
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
CREATE TABLE setting_groups (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
Description varchar(256) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE setting_data_types (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
Description varchar(64) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
Description varchar(256) NOT NULL,
|
||||||
|
Constrained boolean NOT NULL,
|
||||||
|
DataType integer NOT NULL,
|
||||||
|
SettingGroup int NOT NULL,
|
||||||
|
MinValue varchar(6),
|
||||||
|
MaxValue varchar(6),
|
||||||
|
FOREIGN KEY (DataType) REFERENCES setting_data_types(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT,
|
||||||
|
FOREIGN KEY (SettingGroup) REFERENCES setting_groups(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE allowed_setting_values (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
SettingId integer NOT NULL,
|
||||||
|
ItemValue varchar(256),
|
||||||
|
Caption varchar(256),
|
||||||
|
FOREIGN KEY (SettingId) REFERENCES settings(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_settings (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
UserId integer NOT NULL,
|
||||||
|
SettingId integer NOT NULL,
|
||||||
|
AllowedSettingValueId integer,
|
||||||
|
UnconstrainedValue varchar(256),
|
||||||
|
FOREIGN KEY (UserId) REFERENCES users(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT,
|
||||||
|
FOREIGN KEY (SettingId) REFERENCES settings(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT,
|
||||||
|
FOREIGN KEY (AllowedSettingValueId) REFERENCES allowed_setting_values(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE guestbook_settings (
|
||||||
|
Id integer primary key autoincrement,
|
||||||
|
GuestbookId integer NOT NULL,
|
||||||
|
SettingId integer NOT NULL,
|
||||||
|
AllowedSettingValueId integer,
|
||||||
|
UnconstrainedValue varchar(256),
|
||||||
|
FOREIGN KEY (GuestbookId) REFERENCES guestbooks(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT,
|
||||||
|
FOREIGN KEY (SettingId) REFERENCES settings(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT,
|
||||||
|
FOREIGN KEY (AllowedSettingValueId) REFERENCES allowed_setting_values(Id)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
20
migrations/000004_insert_settings_info.down.sql
Normal file
20
migrations/000004_insert_settings_info.down.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
DELETE FROM settings WHERE Description='remote_enabled';
|
||||||
|
DELETE FROM settings WHERE Description='is_visible';
|
||||||
|
DELETE FROM settings WHERE Description='reenable_comments';
|
||||||
|
DELETE FROM settings WHERE Description='commenting_enabled';
|
||||||
|
DELETE FROM settings WHERE Description='local_timezone';
|
||||||
|
|
||||||
|
DELETE FROM setting_data_types WHERE Description='boolean';
|
||||||
|
DELETE FROM setting_data_types WHERE Description='datetime';
|
||||||
|
DELETE FROM setting_data_types WHERE Description='integer';
|
||||||
|
DELETE FROM setting_data_types WHERE Description='alphanumeric';
|
||||||
|
|
||||||
|
DELETE FROM setting_groups WHERE Description='user';
|
||||||
|
DELETE FROM setting_groups WHERE Description='guestbook';
|
||||||
|
|
||||||
|
DELETE FROM allowed_setting_values WHERE Caption='commenting_enabled';
|
||||||
|
DELETE FROM allowed_setting_values WHERE Caption='commenting_disabled';
|
||||||
|
DELETE FROM allowed_setting_values WHERE Caption='guestbook_visible';
|
||||||
|
DELETE FROM allowed_setting_values WHERE Caption='guestbook_invisible';
|
||||||
|
DELETE FROM allowed_setting_values WHERE Caption='remote_enabled';
|
||||||
|
DELETE FROM allowed_setting_values WHERE Caption='remote_disabled';
|
40
migrations/000004_insert_settings_info.up.sql
Normal file
40
migrations/000004_insert_settings_info.up.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
INSERT INTO setting_groups (Description) VALUES ('guestbook');
|
||||||
|
INSERT INTO setting_groups (Description) VALUES ('user');
|
||||||
|
|
||||||
|
INSERT INTO setting_data_types (Description) VALUES ('alphanumeric');
|
||||||
|
INSERT INTO setting_data_types (Description) VALUES ('integer');
|
||||||
|
INSERT INTO setting_data_types (Description) VALUES ('datetime');
|
||||||
|
INSERT INTO setting_data_types (Description) VALUES ('boolean');
|
||||||
|
|
||||||
|
INSERT INTO settings (Description, Constrained, DataType, SettingGroup)
|
||||||
|
SELECT 'local_timezone', 0, t.id, g.id FROM setting_data_types as t INNER JOIN setting_groups AS g WHERE g.Description='user' AND t.Description='alphanumeric';
|
||||||
|
|
||||||
|
INSERT INTO settings (Description, Constrained, DataType, SettingGroup)
|
||||||
|
SELECT 'commenting_enabled', 1, t.id, g.id FROM setting_data_types as t INNER JOIN setting_groups AS g WHERE g.Description='guestbook' AND t.Description='boolean';
|
||||||
|
|
||||||
|
INSERT INTO allowed_setting_values (SettingId, ItemValue, Caption)
|
||||||
|
SELECT settings.id, 'true', 'commenting_enabled' FROM settings WHERE settings.Description='commenting_enabled';
|
||||||
|
|
||||||
|
INSERT INTO allowed_setting_values (SettingId, ItemValue, Caption)
|
||||||
|
SELECT settings.id, 'false', 'commenting_disabled' FROM settings WHERE settings.Description='commenting_enabled';
|
||||||
|
|
||||||
|
INSERT INTO settings (Description, Constrained, DataType, SettingGroup)
|
||||||
|
SELECT 'reenable_comments', 0, t.id, g.id FROM setting_data_types as t INNER JOIN setting_groups as g WHERE g.Description='guestbook' AND t.Description='alphanumeric';
|
||||||
|
|
||||||
|
INSERT INTO settings (Description, Constrained, DataType, SettingGroup)
|
||||||
|
SELECT 'is_visible', 1, t.id, g.id FROM setting_data_types as t INNER JOIN setting_groups as g WHERE g.Description='guestbook' AND t.Description='boolean';
|
||||||
|
|
||||||
|
INSERT INTO allowed_setting_values (SettingId, ItemValue, Caption)
|
||||||
|
SELECT settings.id, 'true', 'guestbook_visible' FROM settings WHERE settings.Description='is_visible';
|
||||||
|
|
||||||
|
INSERT INTO allowed_setting_values (SettingId, ItemValue, Caption)
|
||||||
|
SELECT settings.id, 'false', 'guestbook_invisible' FROM settings WHERE settings.Description='is_visible';
|
||||||
|
|
||||||
|
INSERT INTO settings (Description, Constrained, DataType, SettingGroup)
|
||||||
|
SELECT 'remote_enabled', 1, t.id, g.id FROM setting_data_types as t INNER JOIN setting_groups as g WHERE g.Description='guestbook' AND t.Description='boolean';
|
||||||
|
|
||||||
|
INSERT INTO allowed_setting_values (SettingId, ItemValue, Caption)
|
||||||
|
SELECT settings.id, 'true', 'remote_enabled' FROM settings WHERE settings.Description='remote_enabled';
|
||||||
|
|
||||||
|
INSERT INTO allowed_setting_values (SettingId, ItemValue, Caption)
|
||||||
|
SELECT settings.id, 'false', 'remote_disabled' FROM settings WHERE settings.Description='remote_enabled';
|
1
migrations/000005_normalize_site_urls.down.sql
Normal file
1
migrations/000005_normalize_site_urls.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
UPDATE websites SET SiteUrl = substr(SiteUrl, 8) WHERE substr(SiteUrl, 1, 4) = 'http';
|
1
migrations/000005_normalize_site_urls.up.sql
Normal file
1
migrations/000005_normalize_site_urls.up.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
UPDATE websites SET SiteUrl = 'http://' || SiteUrl WHERE substr(SiteUrl, 1, 4) <> 'http';
|
4
migrations/000006_change_password_field.down.sql
Normal file
4
migrations/000006_change_password_field.down.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld;
|
||||||
|
ALTER TABLE users ADD COLUMN HashedPassword char(60) NOT NULL DEFAULT '0000';
|
||||||
|
UPDATE users SET HashedPassword=HashedPasswordOld;
|
||||||
|
ALTER TABLE users DROP COLUMN HashedPasswordOld;
|
4
migrations/000006_change_password_field.up.sql
Normal file
4
migrations/000006_change_password_field.up.sql
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld;
|
||||||
|
ALTER TABLE users ADD COLUMN HashedPassword char(60) NULL;
|
||||||
|
UPDATE users SET HashedPassword=HashedPasswordOld;
|
||||||
|
ALTER TABLE users DROP COLUMN HashedPasswordOld;
|
1
migrations/000007_add_oidc_subject.down.sql
Normal file
1
migrations/000007_add_oidc_subject.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN OIDCSubject;
|
1
migrations/000007_add_oidc_subject.up.sql
Normal file
1
migrations/000007_add_oidc_subject.up.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN OIDCSubject varchar(255);
|
6
ui/efs.go
Normal file
6
ui/efs.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed "static"
|
||||||
|
var Files embed.FS
|
1
ui/static/css/classless.min.css
vendored
Normal file
1
ui/static/css/classless.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
890
ui/static/css/style.css
Normal file
890
ui/static/css/style.css
Normal file
@ -0,0 +1,890 @@
|
|||||||
|
/* CSS Reset and Base Styles */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS Custom Properties for Theming */
|
||||||
|
:root {
|
||||||
|
/* Light mode colors */
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-hover: #1d4ed8;
|
||||||
|
--color-danger: #dc2626;
|
||||||
|
--color-danger-hover: #b91c1c;
|
||||||
|
--color-warning: #d97706;
|
||||||
|
|
||||||
|
--color-text: #1f2937;
|
||||||
|
--color-text-muted: #6b7280;
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-surface: #f9fafb;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-border-light: #f3f4f6;
|
||||||
|
--color-btn-primary: #FFFFFF;
|
||||||
|
|
||||||
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--space-xs: 0.25rem;
|
||||||
|
--space-sm: 0.5rem;
|
||||||
|
--space-md: 1rem;
|
||||||
|
--space-lg: 1.5rem;
|
||||||
|
--space-xl: 2rem;
|
||||||
|
--space-2xl: 3rem;
|
||||||
|
--space-3xl: 4rem;
|
||||||
|
|
||||||
|
/* Typography scale */
|
||||||
|
--text-xs: 0.75rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-2xl: 1.5rem;
|
||||||
|
--text-3xl: 1.875rem;
|
||||||
|
--text-4xl: 2.25rem;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-sm: 0.125rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--max-width: 1200px;
|
||||||
|
--header-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode colors */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-primary: #97B5F7;
|
||||||
|
--color-primary-hover: #b8cdfa;
|
||||||
|
--color-text: #f9fafb;
|
||||||
|
--color-text-muted: #9ca3af;
|
||||||
|
--color-background: #111827;
|
||||||
|
--color-surface: #1f2937;
|
||||||
|
--color-border: #374151;
|
||||||
|
--color-border-light: #4b5563;
|
||||||
|
--color-btn-primary: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base Typography */
|
||||||
|
body {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-3xl); }
|
||||||
|
h2 { font-size: var(--text-2xl); }
|
||||||
|
h3 { font-size: var(--text-xl); }
|
||||||
|
h4 { font-size: var(--text-lg); }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout Components */
|
||||||
|
body > header {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
height: var(--header-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header h1 a {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header h1 a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-welcome {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
background-color: var(--color-border-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-2xl) var(--space-lg);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links li {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Layout */
|
||||||
|
#dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: var(--space-2xl);
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav > div {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav h3 {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav ul {
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav ul:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav li {
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav a {
|
||||||
|
display: block;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav a:hover {
|
||||||
|
background-color: var(--color-border-light);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
form {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.radio-group {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset.danger-zone {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background-color: rgba(220, 38, 38, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0 var(--space-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="url"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="email"]:focus,
|
||||||
|
input[type="url"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio buttons */
|
||||||
|
input[type="radio"] {
|
||||||
|
margin-right: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
label:has(input[type="radio"]) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.1s ease;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
input[type="submit"]:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active,
|
||||||
|
input[type="submit"]:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background-color: var(--color-danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.outline {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.outline:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button variants */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--color-btn-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--color-primary-hover);
|
||||||
|
color: var(--color-btn-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comments */
|
||||||
|
#comments {
|
||||||
|
margin-top: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
margin: 0 0 var(--space-xs) 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment time {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content p:last-of-type {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
pre {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
background-color: var(--color-border-light);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-example {
|
||||||
|
margin: var(--space-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-example figcaption {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
ul, ol {
|
||||||
|
padding-left: var(--space-xl);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul#websites {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul#websites li {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Website cards */
|
||||||
|
.website-card {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-card:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-header {
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-name {
|
||||||
|
margin: 0 0 var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-name a {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-url {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen reader only content */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notices and alerts */
|
||||||
|
.notice,
|
||||||
|
.warning-notice {
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
border-left: 4px solid var(--color-warning);
|
||||||
|
background-color: rgba(217, 119, 6, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-notice {
|
||||||
|
border-left-color: var(--color-danger);
|
||||||
|
background-color: rgba(220, 38, 38, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section headers */
|
||||||
|
.section-header {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:has(.btn) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h1 {
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero section (index.html) */
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-3xl) 0;
|
||||||
|
background: linear-gradient(135deg, var(--color-surface) 0%, var(--color-background) 100%);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-header h1 {
|
||||||
|
font-size: var(--text-4xl);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Instruction sections (dashboard_main.html) */
|
||||||
|
.instruction-overview {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-method {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
padding-bottom: var(--space-2xl);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-method:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-step {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instruction-step h3 {
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-note {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-section {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
margin-top: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
margin: var(--space-lg) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-indicator{opacity:0}
|
||||||
|
.htmx-request .htmx-indicator{opacity:1; transition: opacity 200ms ease-in;}
|
||||||
|
.htmx-request.htmx-indicator{opacity:1; transition: opacity 200ms ease-in;}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--space-lg: 1rem;
|
||||||
|
--space-xl: 1.5rem;
|
||||||
|
--space-2xl: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header {
|
||||||
|
padding: 0 var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
padding: var(--space-md);
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-welcome {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard nav > div {
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--text-2xl); }
|
||||||
|
h2 { font-size: var(--text-xl); }
|
||||||
|
|
||||||
|
label:has(input[type="radio"]) {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: var(--space-2xl) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-header h1 {
|
||||||
|
font-size: var(--text-3xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header:has(.btn) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header .btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.website-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: var(--space-md);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body > header h1 {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles for better accessibility */
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print styles */
|
||||||
|
@media print {
|
||||||
|
body > header,
|
||||||
|
nav,
|
||||||
|
footer,
|
||||||
|
button,
|
||||||
|
input[type="submit"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
1
ui/static/fontawesome
Submodule
1
ui/static/fontawesome
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 5a85d8a93237e08d9d1f861aa5630f292424cfc0
|
177
ui/static/js/guestbook.js
Normal file
177
ui/static/js/guestbook.js
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
class GuestbookForm extends HTMLElement {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['guestbook'];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this._guestbook = this.getAttribute('guestbook') || '';
|
||||||
|
this._postUrl = `${this._guestbook}/comments/create/remote`
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
if (name === 'guestbook') {
|
||||||
|
this._guestbook = newValue;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
form div {
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
input[type="text"], textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
input[type="submit"] {
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<form action="${this._postUrl}" method="post">
|
||||||
|
<div>
|
||||||
|
<label for="authorname">Name</label>
|
||||||
|
<input type="text" name="authorname" id="authorname"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="authoremail">Email (Optional)</label>
|
||||||
|
<input type="text" name="authoremail" id="authoremail"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="authorsite">Site Url (Optional)</label>
|
||||||
|
<input type="text" name="authorsite" id="authorsite"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="content">Comment</label>
|
||||||
|
<textarea name="content" id="content"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="hidden" value="${window.location.pathname}" name="redirect" id="redirect" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Submit"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CommentList extends HTMLElement {
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ['guestbook'];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.comments = [];
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
if (this.hasAttribute('guestbook')) {
|
||||||
|
this.fetchComments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
if (name === 'guestbook' && oldValue !== newValue) {
|
||||||
|
this.fetchComments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchComments() {
|
||||||
|
const guestbook = this.getAttribute('guestbook');
|
||||||
|
if (!guestbook) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
this.render();
|
||||||
|
|
||||||
|
const commentsUrl = `${guestbook}/comments`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(commentsUrl);
|
||||||
|
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
this.comments = Array.isArray(data) ? data : [];
|
||||||
|
this.loading = false;
|
||||||
|
this.render();
|
||||||
|
} catch (err) {
|
||||||
|
this.error = err.message;
|
||||||
|
this.loading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(isoString) {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.comment-list {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1em;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.comment {
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding: 0.7em 0;
|
||||||
|
}
|
||||||
|
.comment:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.author {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
.timestamp {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
margin: 0.2em 0 0 0;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="comment-list">
|
||||||
|
${this.loading
|
||||||
|
? `<div>Loading comments...</div>`
|
||||||
|
: this.error
|
||||||
|
? `<div class="error">Error: ${this.error}</div>`
|
||||||
|
: this.comments.length === 0
|
||||||
|
? `<div>No comments found.</div>`
|
||||||
|
: this.comments.map(comment => `
|
||||||
|
<div class="comment">
|
||||||
|
<span class="author">${comment.AuthorName || 'Unknown Author'}</span>
|
||||||
|
<span class="timestamp">${this.formatDate(comment.Created)}</span>
|
||||||
|
<div class="text">${comment.CommentText || ''}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('guestbook-form', GuestbookForm);
|
||||||
|
customElements.define('guestbook-comments', CommentList);
|
||||||
|
|
1
ui/static/js/htmx.min.js
vendored
Normal file
1
ui/static/js/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
ui/static/js/main.js
Normal file
13
ui/static/js/main.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
const convertDates = () => {
|
||||||
|
const dates = document.getElementsByTagName("time")
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const e = dates.item(i)
|
||||||
|
const d = e.attributes.getNamedItem("datetime").value
|
||||||
|
const dt = new Date(Date.parse(d))
|
||||||
|
const localtime = dt.toLocaleString("en-US", { "year": "numeric", "month": "short", "day": "numeric", "hour": "numeric", "minute": "numeric", "timeZoneName": "short"})
|
||||||
|
e.innerText = localtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
convertDates()
|
103
ui/views/common.templ
Normal file
103
ui/views/common.templ
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import "git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
import "strconv"
|
||||||
|
import "fmt"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type CommonData struct {
|
||||||
|
CurrentYear int
|
||||||
|
Flash string
|
||||||
|
IsAuthenticated bool
|
||||||
|
CSRFToken string
|
||||||
|
CurrentUser *models.User
|
||||||
|
IsHtmx bool
|
||||||
|
RootUrl string
|
||||||
|
LocalAuthEnabled bool
|
||||||
|
OIDCEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortIdToSlug(shortId uint64) string {
|
||||||
|
return strconv.FormatUint(shortId, 36)
|
||||||
|
}
|
||||||
|
|
||||||
|
func slugToShortId(slug string) uint64 {
|
||||||
|
id, _ := strconv.ParseUint(slug, 36, 64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalUrl(url string) string {
|
||||||
|
if !strings.HasPrefix(url, "http") {
|
||||||
|
return "http://" + url
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
templ commonHeader() {
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">webweav.ing</a></h1>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ topNav(data CommonData) {
|
||||||
|
{{ hxHeaders := fmt.Sprintf("{\"X-CSRF-Token\": \"%s\"}", data.CSRFToken) }}
|
||||||
|
<nav aria-label="Site navigation">
|
||||||
|
<div class="nav-welcome">
|
||||||
|
<span>
|
||||||
|
if data.IsAuthenticated {
|
||||||
|
Welcome, { data.CurrentUser.Username }
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul class="nav-links">
|
||||||
|
if data.IsAuthenticated {
|
||||||
|
<li><a href="/guestbooks">All Guestbooks</a></li>
|
||||||
|
<li><a href="/websites">My Websites</a></li>
|
||||||
|
<li><a href="/users/settings">Settings</a></li>
|
||||||
|
<li><a href="#" hx-post="/users/logout" hx-headers={ hxHeaders }>Logout</a></li>
|
||||||
|
} else {
|
||||||
|
if data.LocalAuthEnabled {
|
||||||
|
<li><a href="/users/register">Create an Account</a></li> |
|
||||||
|
}
|
||||||
|
<li><a href="/users/login">Login</a></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ commonFooter() {
|
||||||
|
<footer>
|
||||||
|
<p>A <a href="https://32bit.cafe" rel="noopener">32bit.cafe</a> Project</p>
|
||||||
|
<ul class="footer-links">
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="/help">Help</a></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ base(title string, data CommonData) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{ title } - webweav.ing</title>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<meta name="htmx-config" content={ `{"includeIndicatorStyles":false}` }/>
|
||||||
|
<link href="/static/css/style.css" rel="stylesheet"/>
|
||||||
|
<link href="/static/fontawesome/css/fontawesome.css" rel="stylesheet"/>
|
||||||
|
<link href="/static/fontawesome/css/solid.css" rel="stylesheet"/>
|
||||||
|
<script src="/static/js/htmx.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
@commonHeader()
|
||||||
|
@topNav(data)
|
||||||
|
<main role="main">
|
||||||
|
if data.Flash != "" {
|
||||||
|
<div class="notice flash">{ data.Flash }</div>
|
||||||
|
}
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
@commonFooter()
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
287
ui/views/common_templ.go
Normal file
287
ui/views/common_templ.go
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.833
|
||||||
|
package views
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import "git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
import "strconv"
|
||||||
|
import "fmt"
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type CommonData struct {
|
||||||
|
CurrentYear int
|
||||||
|
Flash string
|
||||||
|
IsAuthenticated bool
|
||||||
|
CSRFToken string
|
||||||
|
CurrentUser *models.User
|
||||||
|
IsHtmx bool
|
||||||
|
RootUrl string
|
||||||
|
LocalAuthEnabled bool
|
||||||
|
OIDCEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortIdToSlug(shortId uint64) string {
|
||||||
|
return strconv.FormatUint(shortId, 36)
|
||||||
|
}
|
||||||
|
|
||||||
|
func slugToShortId(slug string) uint64 {
|
||||||
|
id, _ := strconv.ParseUint(slug, 36, 64)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func externalUrl(url string) string {
|
||||||
|
if !strings.HasPrefix(url, "http") {
|
||||||
|
return "http://" + url
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonHeader() templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<header><h1><a href=\"/\">webweav.ing</a></h1></header>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func topNav(data CommonData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var2 == nil {
|
||||||
|
templ_7745c5c3_Var2 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
hxHeaders := fmt.Sprintf("{\"X-CSRF-Token\": \"%s\"}", data.CSRFToken)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<nav aria-label=\"Site navigation\"><div class=\"nav-welcome\"><span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.IsAuthenticated {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "Welcome, ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentUser.Username)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 48, Col: 41}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span></div><ul class=\"nav-links\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.IsAuthenticated {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<li><a href=\"/guestbooks\">All Guestbooks</a></li><li><a href=\"/websites\">My Websites</a></li><li><a href=\"/users/settings\">Settings</a></li><li><a href=\"#\" hx-post=\"/users/logout\" hx-headers=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(hxHeaders)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 57, Col: 66}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">Logout</a></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if data.LocalAuthEnabled {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<li><a href=\"/users/register\">Create an Account</a></li>| ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " <li><a href=\"/users/login\">Login</a></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul></nav>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func commonFooter() templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var5 == nil {
|
||||||
|
templ_7745c5c3_Var5 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<footer><p>A <a href=\"https://32bit.cafe\" rel=\"noopener\">32bit.cafe</a> Project</p><ul class=\"footer-links\"><li><a href=\"/about\">About</a></li><li><a href=\"/help\">Help</a></li></ul></footer>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func base(title string, data CommonData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var6 == nil {
|
||||||
|
templ_7745c5c3_Var6 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!doctype html><html lang=\"en\"><head><title>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 82, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><meta name=\"htmx-config\" content=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(`{"includeIndicatorStyles":false}`)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 85, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><link href=\"/static/fontawesome/css/fontawesome.css\" rel=\"stylesheet\"><link href=\"/static/fontawesome/css/solid.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = commonHeader().Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = topNav(data).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<main role=\"main\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.Flash != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"notice flash\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(data.Flash)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 96, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_Var6.Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</main>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = commonFooter().Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
271
ui/views/guestbooks.templ
Normal file
271
ui/views/guestbooks.templ
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
import "git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
import "git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
templ GuestbookDashboardCommentsView(title string, data CommonData, website models.Website, guestbook models.Guestbook, comments []models.GuestbookComment) {
|
||||||
|
@base(title, data) {
|
||||||
|
<div id="dashboard">
|
||||||
|
@wSidebar(website)
|
||||||
|
<div>
|
||||||
|
<section aria-labelledby="comments-management-heading">
|
||||||
|
<header class="section-header">
|
||||||
|
<h1 id="comments-management-heading">Comments on { website.Name }</h1>
|
||||||
|
<p class="section-description">Manage, moderate, and organize comments on your guestbook</p>
|
||||||
|
</header>
|
||||||
|
<hr role="separator"/>
|
||||||
|
if len(comments) == 0 {
|
||||||
|
<p>No comments yet!</p>
|
||||||
|
}
|
||||||
|
for i, c := range comments {
|
||||||
|
@GuestbookDashboardCommentView(data, website, c, i)
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GuestbookDashboardCommentDeletePart(text string) {
|
||||||
|
<div class="comment-update-msg">
|
||||||
|
<p>{ text }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GuestbookDashboardUpdateButtonPart(data CommonData, w models.Website, c models.GuestbookComment) {
|
||||||
|
{{ hxHeaders := fmt.Sprintf("{\"X-CSRF-Token\": \"%s\"}", data.CSRFToken) }}
|
||||||
|
{{ commentUrl := fmt.Sprintf("%s/dashboard/guestbook/comments/%s", wUrl(w), shortIdToSlug(c.ShortId)) }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="outline"
|
||||||
|
hx-put={ commentUrl }
|
||||||
|
hx-headers={ hxHeaders }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
if !c.IsPublished {
|
||||||
|
Publish
|
||||||
|
} else {
|
||||||
|
Hide
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GuestbookDashboardCommentView(data CommonData, w models.Website, c models.GuestbookComment, i int) {
|
||||||
|
{{ commentUrl := fmt.Sprintf("%s/dashboard/guestbook/comments/%s", wUrl(w), shortIdToSlug(c.ShortId)) }}
|
||||||
|
{{ hxHeaders := fmt.Sprintf("{\"X-CSRF-Token\": \"%s\"}", data.CSRFToken) }}
|
||||||
|
{{ authorClass := fmt.Sprintf("comment-author-%d", i) }}
|
||||||
|
<article class="comment" role="article" aria-labelledby={ authorClass }>
|
||||||
|
<div class="comment-update-msg"></div>
|
||||||
|
<header class="comment-header">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<h3 id={ authorClass } class="comment-author">{ c.AuthorName }</h3>
|
||||||
|
<time datetime={ c.Created.In(data.CurrentUser.Settings.LocalTimezone).Format("01-02-2006 03:04PM") }>{ c.Created.In(data.CurrentUser.Settings.LocalTimezone).Format("01-02-2006 03:04PM") }</time>
|
||||||
|
if len(c.AuthorEmail) > 0 {
|
||||||
|
<div class="comment-contact">
|
||||||
|
{{ email := "mailto:" + c.AuthorEmail }}
|
||||||
|
<i class="fa-solid fa-envelope"></i>
|
||||||
|
<a href={ templ.URL(email) } target="_blank">{ c.AuthorEmail }</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if len(c.AuthorSite) > 0 {
|
||||||
|
<div class="comment-contact">
|
||||||
|
<i class="fa-solid fa-house"></i>
|
||||||
|
<a href={ templ.URL(externalUrl(c.AuthorSite)) } target="_blank">{ c.AuthorSite }</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="comment-actions" role="group" aria-label={ fmt.Sprintf("Actions for comment by %s", c.AuthorName) }>
|
||||||
|
if c.Deleted.IsZero() {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
hx-delete={ commentUrl }
|
||||||
|
hx-target="closest article.comment"
|
||||||
|
hx-confirm="Are you sure you want to delete this comment? This action cannot be undone."
|
||||||
|
hx-headers={ hxHeaders }
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
@GuestbookDashboardUpdateButtonPart(data, w, c)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="comment-content">
|
||||||
|
<p>
|
||||||
|
{ c.CommentText }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr role="separator"/>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ commentForm(form forms.CommentCreateForm) {
|
||||||
|
<fieldset>
|
||||||
|
<legend>Leave a comment</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authorname">Name <span aria-label="required">*</span></label>
|
||||||
|
{{ error, exists := form.FieldErrors["authorName"] }}
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="text" name="authorname" id="authorname" required aria-describedby="authorname-help"/>
|
||||||
|
<small id="authorname-help">Your name or handle</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authoremail">Email (Optional)</label>
|
||||||
|
{{ error, exists = form.FieldErrors["authorEmail"] }}
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="email" name="authoremail" id="authoremail" aria-describedby="authoremail-help"/>
|
||||||
|
<small id="authoremail-help">Your email address will only be shared with the guestbook's owner</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="authorsite">Website URL (Optional)</label>
|
||||||
|
{{ error, exists = form.FieldErrors["authorSite"] }}
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="url" name="authorsite" id="authorsite" aria-describedby="authorsite-help"/>
|
||||||
|
<small id="authorsite-help">Link to your website or social profile</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content">Comment <span aria-label="required">*</span></label>
|
||||||
|
{{ error, exists = form.FieldErrors["content"] }}
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<textarea name="content" id="content" required aria-describedby="content-help"></textarea>
|
||||||
|
<small id="content-help">Share your thoughts, feedback, or just say hello!</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit">Submit Comment</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GuestbookView(title string, data CommonData, website models.Website, guestbook models.Guestbook, comments []models.GuestbookComment, form forms.CommentCreateForm) {
|
||||||
|
{{ postUrl := fmt.Sprintf("/websites/%s/guestbook/comments/create", shortIdToSlug(website.ShortId)) }}
|
||||||
|
if data.IsHtmx {
|
||||||
|
@commentForm(form)
|
||||||
|
} else {
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{ title }</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<link href="/static/css/style.css" rel="stylesheet"/>
|
||||||
|
<script src="/static/js/main.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main role="main">
|
||||||
|
<section aria-labelledby="guestbook-heading">
|
||||||
|
<h1>{ website.Name } Guestbook</h1>
|
||||||
|
{ data.Flash }
|
||||||
|
<form action={ templ.URL(postUrl) } method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
@commentForm(form)
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section id="comments" aria-labelledby="comments-heading">
|
||||||
|
<h2 id="comments-heading">Comments</h2>
|
||||||
|
if len(comments) == 0 {
|
||||||
|
<p>No comments yet!</p>
|
||||||
|
}
|
||||||
|
for i, c := range comments {
|
||||||
|
{{ commentAuthorRole := fmt.Sprintf("comment-author-%d", i+1) }}
|
||||||
|
<article class="comment" role="article" aria-labelledby={ commentAuthorRole }>
|
||||||
|
<header class="comment-header">
|
||||||
|
<h3 id={ commentAuthorRole } class="comment-author">
|
||||||
|
if c.AuthorSite != "" {
|
||||||
|
<a href={ templ.URL(externalUrl(c.AuthorSite)) } target="_blank">{ c.AuthorName }</a>
|
||||||
|
} else {
|
||||||
|
{ c.AuthorName }
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<time datetime={ c.Created.Format(time.RFC3339) }>{ c.Created.Format("01-02-2006 03:04PM") }</time>
|
||||||
|
</header>
|
||||||
|
<div class="comment-content">
|
||||||
|
<p>
|
||||||
|
{ c.CommentText }
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ settingRadio(selected bool, name, id, value string) {
|
||||||
|
<input type="radio" name={ name } id={ id } value={ value } selected?={ selected }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ EmbeddableGuestbookCommentForm(data CommonData, w models.Website, f forms.CommentCreateForm) {
|
||||||
|
{{ postUrl := fmt.Sprintf("/websites/%s/guestbook/comments/create?headless=true", shortIdToSlug(w.ShortId)) }}
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link href="/static/css/classless.min.css" rel="stylesheet"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{ data.Flash }
|
||||||
|
<form action={ templ.URL(postUrl) } method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
@commentForm(f)
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AllGuestbooksView(data CommonData, websites []models.Website) {
|
||||||
|
@base("All Guestbooks", data) {
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
This page exists only for testing the service.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
for _, w := range websites {
|
||||||
|
<li>
|
||||||
|
{{ gbUrl := fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(w.ShortId)) }}
|
||||||
|
<a href={ templ.URL(gbUrl) } target="_blank">{ w.Name }</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GuestbookCommentCreateRemoteErrorView(url, err string) {
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="refresh" content={ fmt.Sprintf("3; url='%s'", templ.URL(externalUrl(url))) }/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>
|
||||||
|
An error occurred while posting comment. { err }. Redirecting.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href={ templ.URL(url) }>Redirect</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ GuestbookCommentCreateRemoteSuccessView(url string) {
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="refresh" content={ fmt.Sprintf("3; url='%s'", templ.URL(externalUrl(url))) }/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>
|
||||||
|
Comment successfully posted. Redirecting.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href={ templ.URL(url) }>Redirect</a>
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
1147
ui/views/guestbooks_templ.go
Normal file
1147
ui/views/guestbooks_templ.go
Normal file
File diff suppressed because it is too large
Load Diff
28
ui/views/home.templ
Normal file
28
ui/views/home.templ
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
templ Home(title string, data CommonData) {
|
||||||
|
@base(title, data) {
|
||||||
|
<section aria-labelledby="welcome-heading">
|
||||||
|
<h2 id="welcome-heading">Welcome</h2>
|
||||||
|
<p>Welcome to webweav.ing, a collection of webmastery tools created by the <a href="https://32bit.cafe" rel="noopener">32-Bit Cafe</a>.</p>
|
||||||
|
<aside class="notice" role="alert" aria-labelledby="service-status">
|
||||||
|
<h3 id="service-status" class="sr-only">Service Status Notice</h3>
|
||||||
|
<p><strong>Important:</strong> This service is in a pre-alpha state. Your account and data can disappear at any time.</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ComingSoon(title string, data CommonData) {
|
||||||
|
@base(title, data) {
|
||||||
|
<h2>Coming Soon</h2>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ AboutPage(title string, data CommonData) {
|
||||||
|
@base(title, data) {
|
||||||
|
<section aria-labelledby="about-heading">
|
||||||
|
<h2 id="about-heading">About Webweav.ing</h2>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
152
ui/views/home_templ.go
Normal file
152
ui/views/home_templ.go
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.833
|
||||||
|
package views
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
func Home(title string, data CommonData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section aria-labelledby=\"welcome-heading\"><h2 id=\"welcome-heading\">Welcome</h2><p>Welcome to webweav.ing, a collection of webmastery tools created by the <a href=\"https://32bit.cafe\" rel=\"noopener\">32-Bit Cafe</a>.</p><aside class=\"notice\" role=\"alert\" aria-labelledby=\"service-status\"><h3 id=\"service-status\" class=\"sr-only\">Service Status Notice</h3><p><strong>Important:</strong> This service is in a pre-alpha state. Your account and data can disappear at any time.</p></aside></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base(title, data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ComingSoon(title string, data CommonData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var3 == nil {
|
||||||
|
templ_7745c5c3_Var3 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<h2>Coming Soon</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base(title, data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func AboutPage(title string, data CommonData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var5 == nil {
|
||||||
|
templ_7745c5c3_Var5 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<section aria-labelledby=\"about-heading\"><h2 id=\"about-heading\">About Webweav.ing</h2></section>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base(title, data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
112
ui/views/users.templ
Normal file
112
ui/views/users.templ
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ UserLogin(title string, data CommonData, form forms.UserLoginForm) {
|
||||||
|
@base(title, data) {
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form action="/users/login" method="POST" novalidate>
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
for _, error := range form.NonFieldErrors {
|
||||||
|
<div class="error">{ error }</div>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<label>Email: </label>
|
||||||
|
{{ error, exists := form.FieldErrors["email"] }}
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="email" name="email" value={ form.Email }/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Password: </label>
|
||||||
|
{{ error, exists = form.FieldErrors["password"] }}
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="password" name="password"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Login"/>
|
||||||
|
if data.OIDCEnabled {
|
||||||
|
<a href="/users/login/oidc">Login with SSO</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ UserRegistration(title string, data CommonData, form forms.UserRegistrationForm) {
|
||||||
|
@base(title, data) {
|
||||||
|
<h1>User Registration</h1>
|
||||||
|
<form action="/users/register" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
<div>
|
||||||
|
{{ error, exists := form.FieldErrors["name"] }}
|
||||||
|
<label for="username">Username: </label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="text" name="username" id="username" value={ form.Name } required/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ error, exists = form.FieldErrors["email"] }}
|
||||||
|
<label for="email">Email: </label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="text" name="email" id="email" value={ form.Email } required/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ error, exists = form.FieldErrors["password"] }}
|
||||||
|
<label for="password">Password: </label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ error }</label>
|
||||||
|
}
|
||||||
|
<input type="password" name="password" id="password"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="submit" value="Register"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ UserProfile(title string, data CommonData, user models.User) {
|
||||||
|
@base(title, data) {
|
||||||
|
<h1>{ user.Username }</h1>
|
||||||
|
<p>{ user.Email }</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ UserSettingsView(data CommonData, timezones []string) {
|
||||||
|
{{ user := data.CurrentUser }}
|
||||||
|
@base("User Settings", data) {
|
||||||
|
<div>
|
||||||
|
<section aria-labelledby="user-settings-heading">
|
||||||
|
<form hx-put="/users/settings">
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
<fieldset>
|
||||||
|
<legend id="user-settings-heading">User Settings</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Local Timezone</label>
|
||||||
|
<select name="timezones" id="timezone-select">
|
||||||
|
for _, tz := range timezones {
|
||||||
|
if tz == user.Settings.LocalTimezone.String() {
|
||||||
|
<option value={ tz } selected="true">{ tz }</option>
|
||||||
|
} else {
|
||||||
|
<option value={ tz }>{ tz }</option>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
543
ui/views/users_templ.go
Normal file
543
ui/views/users_templ.go
Normal file
@ -0,0 +1,543 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.833
|
||||||
|
package views
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UserLogin(title string, data CommonData, form forms.UserLoginForm) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1>Login</h1><form action=\"/users/login\" method=\"POST\" novalidate><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 12, Col: 64}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, error := range form.NonFieldErrors {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(error)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 14, Col: 30}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div><label>Email: </label>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
error, exists := form.FieldErrors["email"]
|
||||||
|
if exists {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<label class=\"error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(error)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 20, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<input type=\"email\" name=\"email\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 22, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"></div><div><label>Password: </label>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
error, exists = form.FieldErrors["password"]
|
||||||
|
if exists {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<label class=\"error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(error)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 28, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"Login\"> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.OIDCEnabled {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"/users/login/oidc\">Login with SSO</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base(title, data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserRegistration(title string, data CommonData, form forms.UserRegistrationForm) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var8 == nil {
|
||||||
|
templ_7745c5c3_Var8 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var9 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 46, Col: 64}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
error, exists := form.FieldErrors["name"]
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<label for=\"username\">Username: </label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label class=\"error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(error)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 51, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<input type=\"text\" name=\"username\" id=\"username\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 53, Col: 70}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" required></div><div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
error, exists = form.FieldErrors["email"]
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"email\">Email: </label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<label class=\"error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(error)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 59, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<input type=\"text\" name=\"email\" id=\"email\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 61, Col: 65}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></div><div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
error, exists = form.FieldErrors["password"]
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<label for=\"password\">Password: </label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label class=\"error\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(error)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 67, Col: 33}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</label> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base(title, data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserProfile(title string, data CommonData, user models.User) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var16 == nil {
|
||||||
|
templ_7745c5c3_Var16 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<h1>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 80, Col: 21}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 81, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base(title, data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserSettingsView(data CommonData, timezones []string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var20 == nil {
|
||||||
|
templ_7745c5c3_Var20 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
user := data.CurrentUser
|
||||||
|
templ_7745c5c3_Var21 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div><section aria-labelledby=\"user-settings-heading\"><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 91, Col: 66}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"><fieldset><legend id=\"user-settings-heading\">User Settings</legend><div class=\"form-group\"><label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, tz := range timezones {
|
||||||
|
if tz == user.Settings.LocalTimezone.String() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 99, Col: 28}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" selected=\"true\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 string
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 99, Col: 51}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 101, Col: 28}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 101, Col: 35}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</select></div></fieldset><button type=\"submit\">Save Settings</button></form></section></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = base("User Settings", data).Render(templ.WithChildren(ctx, templ_7745c5c3_Var21), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
381
ui/views/websites.templ
Normal file
381
ui/views/websites.templ
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
package views
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/forms"
|
||||||
|
"git.32bit.cafe/32bitcafe/guestbook/internal/models"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func wUrl(w models.Website) string {
|
||||||
|
return fmt.Sprintf("/websites/%s", shortIdToSlug(w.ShortId))
|
||||||
|
}
|
||||||
|
|
||||||
|
templ wSidebar(website models.Website) {
|
||||||
|
{{ dashUrl := wUrl(website) + "/dashboard" }}
|
||||||
|
{{ gbUrl := wUrl(website) + "/guestbook" }}
|
||||||
|
<nav aria-label="Dashboard navigation">
|
||||||
|
<div>
|
||||||
|
<section aria-labelledby="main-nav-heading">
|
||||||
|
<h3 id="main-nav-heading">Website</h3>
|
||||||
|
<ul role="list">
|
||||||
|
<li><a href={ templ.URL(dashUrl) } aria-current="page">Dashboard</a></li>
|
||||||
|
<li><a href={ templ.URL(dashUrl + "/settings") }>Settings</a></li>
|
||||||
|
<li><a href={ templ.URL(externalUrl(website.Url.String())) } target="_blank">View Website</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<section aria-labelledby="guestbook-nav-heading">
|
||||||
|
<h3 id="guestbook-nav-heading">Guestbook</h3>
|
||||||
|
<ul role="list">
|
||||||
|
<li><a href={ templ.URL(gbUrl) } target="_blank">View Guestbook</a></li>
|
||||||
|
<li><a href={ templ.URL(dashUrl + "/guestbook/comments") }>Manage Comments</a></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<section aria-labelledby="feeds-nav-heading">
|
||||||
|
<h3 id="feeds-nav-heading">Feeds</h3>
|
||||||
|
<p>Coming Soon</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ websiteCreateForm(csrfToken string, form forms.WebsiteCreateForm) {
|
||||||
|
<input type="hidden" name="csrf_token" value={ csrfToken }/>
|
||||||
|
<fieldset>
|
||||||
|
<legend id="website-create-heading">Website Settings</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ err, exists := form.FieldErrors["ws_name"] }}
|
||||||
|
<label for="ws_name">Site Name <span aria-label="required">*</span></label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
<input type="text" name="ws_name" id="sitename" value={ form.Name } required aria-describedby="sitename-help"/>
|
||||||
|
<small id="sitename-help">The display name for your website</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ err, exists = form.FieldErrors["ws_url"] }}
|
||||||
|
<label for="ws_url">Site URL <span aria-label="required">*</span></label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
<input type="url" name="ws_url" id="ws_url" value={ form.SiteUrl } required aria-describedby="siteurl-help"/>
|
||||||
|
<small id="siteurl-help">The full URL where your website can be accessed</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ err, exists = form.FieldErrors["ws_author"] }}
|
||||||
|
<label for="ws_author">Site Author <span aria-label="required">*</span></label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
<input type="text" name="ws_author" id="authorname" value={ form.AuthorName } required aria-describedby="authorname-help"/>
|
||||||
|
<small id="authorname-help">Your name or the website owner's name</small>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div>
|
||||||
|
<button type="submit">Add Website</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ WebsiteList(title string, data CommonData, websites []models.Website) {
|
||||||
|
@base(title, data) {
|
||||||
|
<section aria-labelledby="websites-heading">
|
||||||
|
<header class="section-header">
|
||||||
|
<h1 id="websites-heading">My Websites</h1>
|
||||||
|
<a href="/websites/create" class="btn btn-primary" role="button" aria-label="Create a new website">Add Website</a>
|
||||||
|
</header>
|
||||||
|
<div class="websites-container">
|
||||||
|
if len(websites) == 0 {
|
||||||
|
<h2>No Websites yet.</h2>
|
||||||
|
<p>Create your first website to get started with webweav.ing tools.</p>
|
||||||
|
<a href="/websites/create" class="btn btn-primary">Create Your First Website</a>
|
||||||
|
} else {
|
||||||
|
<ul
|
||||||
|
id="websites"
|
||||||
|
role="list"
|
||||||
|
aria-label="Your websites"
|
||||||
|
hx-get="/websites"
|
||||||
|
hx-trigger="newWebsite from:body"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
for _, w := range websites {
|
||||||
|
<li role="listitem">
|
||||||
|
<article class="website-card">
|
||||||
|
<header class="website-header">
|
||||||
|
<h2 class="website-name">
|
||||||
|
<a href={ templ.URL(wUrl(w) + "/dashboard") } aria-label={ fmt.Sprintf("Manage %s website dashboard", w.Name) }>
|
||||||
|
{ w.Name }
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<span class="website-url" aria-label="Website URL">{ w.Url.String() }</span>
|
||||||
|
</header>
|
||||||
|
<div class="website-actions">
|
||||||
|
<a href={ templ.URL(wUrl(w) + "/dashboard") } class="btn btn-outline" aria-label={ fmt.Sprintf("Open %s dashboard", w.Name) }>Dashboard</a>
|
||||||
|
<a href={ templ.URL(wUrl(w) + "/guestbook") } target="_blank" rel="noopener" class="btn btn-outline" aria-label={ fmt.Sprintf("View %s website guestbook", w.Name) }>View Guestbook</a>
|
||||||
|
<a href={ templ.URL(externalUrl(w.Url.String())) } target="_blank" rel="noopener" class="btn btn-outline" aria-label={ fmt.Sprintf("View %s website", w.Name) }>Visit Site</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ WebsiteDashboard(title string, data CommonData, website models.Website) {
|
||||||
|
@base(title, data) {
|
||||||
|
<div id="dashboard">
|
||||||
|
@wSidebar(website)
|
||||||
|
<div>
|
||||||
|
{{ gbUrl := fmt.Sprintf("https://%s/websites/%s/guestbook", data.RootUrl, shortIdToSlug(website.ShortId)) }}
|
||||||
|
<section aria-labelledby="embed-instructions">
|
||||||
|
<h1 id="embed-instructions">Embed your Guestbook</h1>
|
||||||
|
<div class="instruction-overview">
|
||||||
|
<p>There are two ways to add your guestbook to your website: using our JavaScript component (recommended) or with an iframe.</p>
|
||||||
|
</div>
|
||||||
|
<article class="instruction-method">
|
||||||
|
<h2>Method 1: JavaScript Component (Recommended)</h2>
|
||||||
|
<div class="instruction-step">
|
||||||
|
<h3>Step 1: Download and upload the component</h3>
|
||||||
|
<p>Download <a href="/static/js/guestbook.js" download>this JavaScript WebComponent</a> and upload it to your website's JavaScript folder.</p>
|
||||||
|
</div>
|
||||||
|
<div class="instruction-step">
|
||||||
|
<h3>Step 2: Include in your HTML head</h3>
|
||||||
|
<figure class="code-example">
|
||||||
|
<figcaption>Add this script tag to your <head> section:</figcaption>
|
||||||
|
<pre>
|
||||||
|
<code id="guestbookSnippet" aria-label="HTML head script tag">
|
||||||
|
<head>
|
||||||
|
<script type="module" src="js/guestbook.js"></script>
|
||||||
|
</head>
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
<div class="instruction-step">
|
||||||
|
<h3>Step 3: Add the custom elements</h3>
|
||||||
|
<figure class="code-example">
|
||||||
|
<figcaption>Place these elements where you want your guestbook to appear:</figcaption>
|
||||||
|
<pre>
|
||||||
|
<code aria-label="Custom guestbook elements">
|
||||||
|
{ fmt.Sprintf(`<guestbook-form guestbook="%s"></guestbook-form>
|
||||||
|
<guestbook-comments guestbook="%s"></guestbook-comments>`, gbUrl, gbUrl) }
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="instruction-method">
|
||||||
|
<h2>Method 2: iframe (Alternative)</h2>
|
||||||
|
<details>
|
||||||
|
<summary>Use this method if your web host doesn't allow CORS requests</summary>
|
||||||
|
<div class="instruction-step">
|
||||||
|
<p>If your hosting provider blocks cross-origin requests, you can embed the guestbook using an iframe instead:</p>
|
||||||
|
<figure class="code-example">
|
||||||
|
<figcaption>iframe embedding code:</figcaption>
|
||||||
|
<pre>
|
||||||
|
<code aria-label="iframe embedding code">
|
||||||
|
{ fmt.Sprintf(`<iframe src="%s"
|
||||||
|
title="Guestbook"
|
||||||
|
width="100%%"
|
||||||
|
height="600"
|
||||||
|
style="border: 1px solid #ccc; border-radius: 8px;"
|
||||||
|
>
|
||||||
|
<p>Your browser does not support iframes. <a href="%s">View the guestbook directly.</a></p>
|
||||||
|
</iframe>`, gbUrl, gbUrl) }
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</figure>
|
||||||
|
<aside role="note" class="method-note">
|
||||||
|
<p><strong>Note:</strong> The iframe method may have styling limitations and won't integrate as seamlessly with your site's design.</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</article>
|
||||||
|
<aside role="complementary" class="help-section">
|
||||||
|
<h2>Need Help?</h2>
|
||||||
|
<p>If you're having trouble embedding your guestbook, check out our <a href="/help">help documentation</a> or contact support.</p>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ websiteSettingsForm(data CommonData, website models.Website, form forms.WebsiteSettingsForm) {
|
||||||
|
<legend id="website-settings-heading">Website Settings</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ err, exists := form.FieldErrors["ws_name"] }}
|
||||||
|
<label for="ws_name">Site Name <span aria-label="required">*</span></label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
if form.SiteName != "" {
|
||||||
|
<input type="text" name="ws_name" id="sitename" value={ form.SiteName } required aria-describedby="sitename-help"/>
|
||||||
|
} else {
|
||||||
|
<input type="text" name="ws_name" id="sitename" value={ website.Name } required aria-describedby="sitename-help"/>
|
||||||
|
}
|
||||||
|
<small id="sitename-help">The display name for your website</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ err, exists = form.FieldErrors["ws_url"] }}
|
||||||
|
<label for="ws_url">Site URL <span aria-label="required">*</span></label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
if form.SiteUrl != "" {
|
||||||
|
<input type="url" name="ws_url" id="ws_url" value={ form.SiteUrl } required aria-describedby="siteurl-help"/>
|
||||||
|
} else {
|
||||||
|
<input type="url" name="ws_url" id="ws_url" value={ website.Url.String() } required aria-describedby="siteurl-help"/>
|
||||||
|
}
|
||||||
|
<small id="siteurl-help">The full URL where your website can be accessed</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ err, exists = form.FieldErrors["ws_author"] }}
|
||||||
|
<label for="ws_author">Site Author <span aria-label="required">*</span></label>
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
if form.AuthorName != "" {
|
||||||
|
<input type="text" name="ws_author" id="authorname" value={ form.AuthorName } required aria-describedby="authorname-help"/>
|
||||||
|
} else {
|
||||||
|
<input type="text" name="ws_author" id="authorname" value={ website.AuthorName } required aria-describedby="authorname-help"/>
|
||||||
|
}
|
||||||
|
<small id="authorname-help">Your name or the website owner's name</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ guestbookSettingsForm(data CommonData, website models.Website, gb models.Guestbook, form forms.WebsiteSettingsForm) {
|
||||||
|
<legend>Guestbook Settings</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<fieldset class="radio-group">
|
||||||
|
<legend>Guestbook Visibility</legend>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="gb_visible" id="gb_visible_true" value="true" checked?={ gb.Settings.IsVisible }/>
|
||||||
|
Public
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="gb_visible" id="gb_visible_false" value="false" checked?={ !gb.Settings.IsVisible }/>
|
||||||
|
Private
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="gb-commenting">Guestbook Commenting</label>
|
||||||
|
<select name="gb_commenting" id="gb-commenting" aria-describedby="commenting-help">
|
||||||
|
<option value="true" selected?={ gb.Settings.IsCommentingEnabled }>Enabled</option>
|
||||||
|
<option value="1h">Disabled for 1 Hour</option>
|
||||||
|
<option value="4h">Disabled for 4 Hours</option>
|
||||||
|
<option value="8h">Disabled for 8 Hours</option>
|
||||||
|
<option value="24h">Disabled for 1 Day</option>
|
||||||
|
<option value="72h">Disabled for 3 Days</option>
|
||||||
|
<option value="168h">Disabled for 7 Days</option>
|
||||||
|
<option value="false" selected?={ !gb.Settings.IsCommentingEnabled }>Disabled</option>
|
||||||
|
</select>
|
||||||
|
if !website.Guestbook.CanComment() {
|
||||||
|
{{ localtime := gb.Settings.ReenableCommenting.In(data.CurrentUser.Settings.LocalTimezone) }}
|
||||||
|
<label>Commenting re-enabled on <time value={ localtime.Format(time.RFC3339) }>{ localtime.Format("2 January 2006") } at { localtime.Format("3:04PM MST") }</time></label>
|
||||||
|
}
|
||||||
|
<small id="commenting-help">Control when users can post new comments</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<fieldset class="radio-group">
|
||||||
|
<legend>Enable Widgets</legend>
|
||||||
|
<label for="gb_remote_true">
|
||||||
|
<input type="radio" name="gb_remote" id="gb_remote_true" value="true" checked?={ gb.Settings.AllowRemoteHostAccess }/>
|
||||||
|
Yes
|
||||||
|
</label>
|
||||||
|
<label for="gb_remote_false">
|
||||||
|
<input type="radio" name="gb_remote" id="gb_remote_false" value="false" checked?={ !gb.Settings.AllowRemoteHostAccess }/>
|
||||||
|
No
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
<small>Allow embedding guestbook on external websites</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ SettingsForm(data CommonData, website models.Website, form forms.WebsiteSettingsForm, msg string) {
|
||||||
|
{{ putUrl := fmt.Sprintf("/websites/%s/settings", shortIdToSlug(website.ShortId)) }}
|
||||||
|
{{ gb := website.Guestbook }}
|
||||||
|
<form hx-put={ putUrl } hx-swap="outerHTML">
|
||||||
|
<p>
|
||||||
|
{ msg }
|
||||||
|
</p>
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
<fieldset>
|
||||||
|
@websiteSettingsForm(data, website, form)
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
@guestbookSettingsForm(data, website, gb, form)
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Save Settings</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ DeleteForm(data CommonData, website models.Website, form forms.WebsiteDeleteForm) {
|
||||||
|
{{ putUrl := fmt.Sprintf("/websites/%s", shortIdToSlug(website.ShortId)) }}
|
||||||
|
<form hx-put={ putUrl } hx-swap="outerHTML" aria-label="Delete website">
|
||||||
|
<input type="hidden" name="csrf_token" value={ data.CSRFToken }/>
|
||||||
|
<fieldset class="danger-zone">
|
||||||
|
<legend id="danger-zone-heading">Delete Website</legend>
|
||||||
|
<aside role="alert" class="warning-notice">
|
||||||
|
<p><strong>Warning:</strong> Deleting a website is permanent. Be absolutely sure before proceeding.</p>
|
||||||
|
</aside>
|
||||||
|
{{ err, exists := form.FieldErrors["delete"] }}
|
||||||
|
<div class="form-group">
|
||||||
|
if exists {
|
||||||
|
<label class="error">{ err }</label>
|
||||||
|
}
|
||||||
|
<label for="delete">Type your site name to confirm deletion</label>
|
||||||
|
<input type="text" name="delete" id="delete" required aria-describedby="delete-help" placeholder={ website.Name }/>
|
||||||
|
<small id="delete-help">Type { website.Name } exactly as shown to confirm deletion</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="danger">Delete Website</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
templ WebsiteDashboardSettings(data CommonData, website models.Website, form forms.WebsiteSettingsForm) {
|
||||||
|
{{ title := fmt.Sprintf("%s - Settings", website.Name) }}
|
||||||
|
@base(title, data) {
|
||||||
|
<div id="dashboard">
|
||||||
|
@wSidebar(website)
|
||||||
|
<div>
|
||||||
|
<section aria-labelledby="website-settings-heading">
|
||||||
|
@SettingsForm(data, website, form, "")
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="danger-zone-heading">
|
||||||
|
@DeleteForm(data, website, forms.WebsiteDeleteForm{})
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ WebsiteDashboardComingSoon(title string, data CommonData, website models.Website) {
|
||||||
|
@base(title, data) {
|
||||||
|
<div id="dashboard">
|
||||||
|
@wSidebar(website)
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Coming Soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ WebsiteCreate(title string, data CommonData, form forms.WebsiteCreateForm) {
|
||||||
|
@base(title, data) {
|
||||||
|
<section aria-labelledby="website-create-heading">
|
||||||
|
<form action="/websites/create" method="post">
|
||||||
|
@websiteCreateForm(data.CSRFToken, form)
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
1284
ui/views/websites_templ.go
Normal file
1284
ui/views/websites_templ.go
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user