Compare commits

..

9 Commits

Author SHA1 Message Date
Joca 8ff0b7f2c2
Harden profile image uploads. 2026-03-06 14:58:53 -03:00
Joca 7a5b0f8ca5
Add CSRF checks to chat. 2026-03-06 14:53:40 -03:00
Joca f3749b3812
Add CSRF checks to likes. 2026-03-06 14:53:23 -03:00
Joca ca5ad07f26
Add CSRF checks to news. 2026-03-06 14:53:20 -03:00
Joca 47ebf77f24
Add CSRF checks to profile editing. 2026-03-06 14:53:17 -03:00
Joca 730b05dd58
Add CSRF checks to preferences. 2026-03-06 14:53:14 -03:00
Joca 82a7e48827
Add CSRF checks to boards and threads. 2026-03-06 14:51:54 -03:00
Joca 48363ccef9
Add CSRF checks to signup. 2026-03-06 14:51:14 -03:00
Joca ff4e05fd0b
Add CSRF checks to login. 2026-03-06 14:50:50 -03:00
25 changed files with 342 additions and 52 deletions

View File

@ -20,6 +20,7 @@ type PageData struct {
CurrentURL string
ContentTemplate string
BodyClass string
CSRFToken string
}
type Config struct {

View File

@ -58,6 +58,11 @@ func BoardHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "create_thread" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
title := r.FormValue("title")
if title == "" {
http.Error(w, "Thread title is required", http.StatusBadRequest)
@ -118,6 +123,7 @@ func BoardHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Board: *board,
Threads: threads,

View File

@ -26,6 +26,11 @@ func BoardsHandler(app *App) http.HandlerFunc {
}
if r.Method == http.MethodPost && loggedIn && isAdmin {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
name := r.FormValue("name")
description := r.FormValue("description")
boardType := r.FormValue("type")
@ -106,6 +111,7 @@ func BoardsHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
PublicBoards: publicBoards,
PrivateBoards: privateBoards,

View File

@ -147,6 +147,11 @@ func ChatHandler(app *App) http.HandlerFunc {
currentUsername := currentUser.Username
if r.URL.Query().Get("ws") == "true" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Error upgrading to WebSocket: %v", err)
@ -240,6 +245,7 @@ func ChatHandler(app *App) http.HandlerFunc {
CurrentURL: r.URL.RequestURI(),
ContentTemplate: "chat-content",
BodyClass: "chat-page",
CSRFToken: app.csrfToken(session),
},
Board: *board,
Messages: messages,

55
handlers/csrf.go Normal file
View File

@ -0,0 +1,55 @@
package handlers
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"github.com/gorilla/sessions"
)
const csrfSessionKey = "csrf_token"
func (app *App) ensureCSRFToken(session *sessions.Session) (string, error) {
if token, ok := session.Values[csrfSessionKey].(string); ok && token != "" {
return token, nil
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := base64.RawURLEncoding.EncodeToString(raw)
session.Values[csrfSessionKey] = token
return token, nil
}
func (app *App) csrfToken(session *sessions.Session) string {
token, err := app.ensureCSRFToken(session)
if err != nil {
return ""
}
return token
}
func (app *App) validateCSRFToken(r *http.Request, session *sessions.Session) bool {
expected, ok := session.Values[csrfSessionKey].(string)
if !ok || expected == "" {
return false
}
provided := r.Header.Get("X-CSRF-Token")
if provided == "" {
provided = r.FormValue("csrf_token")
}
if provided == "" {
provided = r.URL.Query().Get("csrf_token")
}
if len(provided) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}

View File

@ -1,9 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"path/filepath"
"strconv"
"threadr/models"
)
@ -23,10 +21,21 @@ func FileHandler(app *App) http.HandlerFunc {
return
}
fileExt := filepath.Ext(file.OriginalName)
fileName := fmt.Sprintf("%d%s", fileID, fileExt)
filePath := filepath.Join(app.Config.FileStorageDir, fileName)
isProfileImage, err := models.IsProfileImageFile(app.DB, fileID)
if err != nil || !isProfileImage {
http.NotFound(w, r)
return
}
filePath, contentType, ok := models.ResolveStoredImagePath(app.Config.FileStorageDir, file)
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, max-age=300")
http.ServeFile(w, r, filePath)
}
}

View File

@ -23,6 +23,10 @@ func LikeHandler(app *App) http.HandlerFunc {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
postIDStr := r.FormValue("post_id")
postID, err := strconv.Atoi(postIDStr)

View File

@ -13,6 +13,11 @@ func LoginHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
user, err := models.GetUserByUsername(app.DB, username)
@ -53,6 +58,7 @@ func LoginHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "",
}

View File

@ -26,6 +26,11 @@ func NewsHandler(app *App) http.HandlerFunc {
}
if r.Method == http.MethodPost && loggedIn && isAdmin {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
if action := r.URL.Query().Get("action"); action == "delete" {
newsIDStr := r.URL.Query().Get("id")
newsID, err := strconv.Atoi(newsIDStr)
@ -85,6 +90,7 @@ func NewsHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
News: newsItems,
IsAdmin: isAdmin,

View File

@ -19,6 +19,11 @@ func PreferencesHandler(app *App) http.HandlerFunc {
// Handle POST request (saving preferences)
if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Get form values
autoSaveDrafts := r.FormValue("auto_save_drafts") == "on"
@ -70,6 +75,7 @@ func PreferencesHandler(app *App) http.HandlerFunc {
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
ContentTemplate: "preferences-content",
CSRFToken: app.csrfToken(session),
},
Preferences: prefs,
ShowSuccess: showSuccess,

View File

@ -1,18 +1,29 @@
package handlers
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
_ "image/png"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"threadr/models"
"github.com/gorilla/sessions"
)
const maxProfileImageBytes = 2 << 20
func ProfileEditHandler(app *App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value("session").(*sessions.Session)
@ -23,62 +34,70 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
}
if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxProfileImageBytes+(256<<10))
// Handle file upload
file, handler, err := r.FormFile("pfp")
if err == nil {
defer file.Close()
// Create a hash of the file
h := sha256.New()
if _, err := io.Copy(h, file); err != nil {
log.Printf("Error hashing file: %v", err)
http.Error(w, "Failed to process file", http.StatusInternalServerError)
return
}
fileHash := fmt.Sprintf("%x", h.Sum(nil))
// Create file record in the database
fileRecord := models.File{
OriginalName: handler.Filename,
Hash: fileHash,
HashAlgorithm: "sha256",
}
fileID, err := models.CreateFile(app.DB, fileRecord)
fileHash, fileID, err := saveProfileImageUpload(app, file)
if err != nil {
log.Printf("Error creating file record: %v", err)
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
return
}
// Save the file to disk
fileExt := filepath.Ext(handler.Filename)
newFileName := fmt.Sprintf("%d%s", fileID, fileExt)
filePath := filepath.Join(app.Config.FileStorageDir, newFileName)
// Reset file pointer
file.Seek(0, 0)
dst, err := os.Create(filePath)
if err != nil {
log.Printf("Error creating file on disk: %v", err)
if errors.Is(err, errInvalidProfileImage) || errors.Is(err, errProfileImageTooLarge) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Error saving profile image: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
log.Printf("Error saving file to disk: %v", err)
// Create file record in the database
fileRecord := models.File{
OriginalName: sanitizeOriginalFileName(handler.Filename),
Hash: fileHash,
HashAlgorithm: "sha256",
}
createdFileID, err := models.CreateFile(app.DB, fileRecord)
if err != nil {
log.Printf("Error creating file record: %v", err)
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
_ = os.Remove(fileID)
return
}
finalPath := filepath.Join(app.Config.FileStorageDir, models.ProfileImageStorageName(createdFileID))
if err := os.Rename(fileID, finalPath); err != nil {
_ = os.Remove(fileID)
_ = models.DeleteFileByID(app.DB, createdFileID)
log.Printf("Error moving file on disk: %v", err)
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
// Update user's pfp_file_id
err = models.UpdateUserPfp(app.DB, userID, fileID)
err = models.UpdateUserPfp(app.DB, userID, createdFileID)
if err != nil {
_ = os.Remove(finalPath)
_ = models.DeleteFileByID(app.DB, createdFileID)
log.Printf("Error updating user pfp: %v", err)
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
return
}
} else if err != nil && !errors.Is(err, http.ErrMissingFile) {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) || strings.Contains(err.Error(), "request body too large") {
http.Error(w, errProfileImageTooLarge.Error(), http.StatusBadRequest)
return
}
log.Printf("Error reading upload: %v", err)
http.Error(w, "Failed to process file upload", http.StatusBadRequest)
return
}
// Update other profile fields
@ -118,6 +137,7 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
User: *user,
}
@ -128,3 +148,67 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
}
}
}
var (
errInvalidProfileImage = errors.New("Profile picture must be a PNG, JPEG, or GIF image")
errProfileImageTooLarge = errors.New("Profile picture must be 2 MB or smaller")
)
func saveProfileImageUpload(app *App, file multipart.File) (string, string, error) {
limitedReader := io.LimitReader(file, maxProfileImageBytes+1)
data, err := io.ReadAll(limitedReader)
if err != nil {
return "", "", err
}
if int64(len(data)) > maxProfileImageBytes {
return "", "", errProfileImageTooLarge
}
contentType := http.DetectContentType(data)
if !isAllowedProfileImageType(contentType) {
return "", "", errInvalidProfileImage
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "", "", errInvalidProfileImage
}
tmpFile, err := os.CreateTemp(app.Config.FileStorageDir, "pfp-*.png")
if err != nil {
return "", "", err
}
defer func() {
_ = tmpFile.Close()
}()
hash := sha256.Sum256(data)
if err := png.Encode(tmpFile, img); err != nil {
_ = os.Remove(tmpFile.Name())
return "", "", err
}
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpFile.Name())
return "", "", err
}
return fmt.Sprintf("%x", hash[:]), tmpFile.Name(), nil
}
func sanitizeOriginalFileName(name string) string {
base := filepath.Base(strings.TrimSpace(name))
if base == "." || base == string(filepath.Separator) || base == "" {
return "profile.png"
}
return base
}
func isAllowedProfileImageType(contentType string) bool {
switch contentType {
case "image/png", "image/jpeg", "image/gif":
return true
default:
return false
}
}

View File

@ -12,6 +12,11 @@ func SignupHandler(app *App) http.HandlerFunc {
session := r.Context().Value("session").(*sessions.Session)
cookie, _ := r.Cookie("threadr_cookie_banner")
if r.Method == http.MethodPost {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
passwordConfirm := r.FormValue("password_confirm")
@ -31,6 +36,7 @@ func SignupHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "Passwords do not match. Please try again.",
}
@ -57,6 +63,7 @@ func SignupHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "An error occurred during sign up. Please try again.",
}
@ -82,6 +89,7 @@ func SignupHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Error: "",
}

View File

@ -59,6 +59,11 @@ func ThreadHandler(app *App) http.HandlerFunc {
if r.Method == http.MethodPost && loggedIn {
action := r.URL.Query().Get("action")
if action == "submit" {
if !app.validateCSRFToken(r, session) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
content := r.FormValue("content")
replyToStr := r.FormValue("reply_to")
if replyToStr == "" {
@ -164,6 +169,7 @@ func ThreadHandler(app *App) http.HandlerFunc {
BasePath: app.Config.ThreadrDir,
StaticPath: app.Config.ThreadrDir + "/static",
CurrentURL: r.URL.RequestURI(),
CSRFToken: app.csrfToken(session),
},
Thread: *thread,
Board: *board,

View File

@ -2,8 +2,14 @@ package models
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
)
const ProfileImageExtension = ".png"
type File struct {
ID int
OriginalName string
@ -25,6 +31,15 @@ func GetFileByID(db *sql.DB, id int64) (*File, error) {
return file, nil
}
func IsProfileImageFile(db *sql.DB, id int64) (bool, error) {
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM users WHERE pfp_file_id = ?)", id).Scan(&exists)
if err != nil {
return false, err
}
return exists, nil
}
func CreateFile(db *sql.DB, file File) (int64, error) {
query := "INSERT INTO files (original_name, hash, hash_algorithm) VALUES (?, ?, ?)"
result, err := db.Exec(query, file.OriginalName, file.Hash, file.HashAlgorithm)
@ -33,3 +48,58 @@ func CreateFile(db *sql.DB, file File) (int64, error) {
}
return result.LastInsertId()
}
func DeleteFileByID(db *sql.DB, id int64) error {
_, err := db.Exec("DELETE FROM files WHERE id = ?", id)
return err
}
func ProfileImageStorageName(id int64) string {
return fmt.Sprintf("%d%s", id, ProfileImageExtension)
}
func LegacyImageStorageName(id int64, originalName string) (string, bool) {
ext := strings.ToLower(filepath.Ext(originalName))
if !allowedImageExtension(ext) {
return "", false
}
return fmt.Sprintf("%d%s", id, ext), true
}
func ProfileImageContentType(fileName string) string {
switch strings.ToLower(filepath.Ext(fileName)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
default:
return "image/png"
}
}
func ResolveStoredImagePath(storageDir string, file *File) (string, string, bool) {
currentPath := filepath.Join(storageDir, ProfileImageStorageName(int64(file.ID)))
if _, err := os.Stat(currentPath); err == nil {
return currentPath, ProfileImageContentType(currentPath), true
}
legacyName, ok := LegacyImageStorageName(int64(file.ID), file.OriginalName)
if !ok {
return "", "", false
}
legacyPath := filepath.Join(storageDir, legacyName)
if _, err := os.Stat(legacyPath); err == nil {
return legacyPath, ProfileImageContentType(legacyPath), true
}
return "", "", false
}
func allowedImageExtension(ext string) bool {
switch ext {
case ".png", ".jpg", ".jpeg", ".gif":
return true
default:
return false
}
}

View File

@ -9,6 +9,7 @@
const boardId = chatContainer.dataset.boardId;
const basePath = chatContainer.dataset.basePath || '';
const currentUsername = chatContainer.dataset.currentUsername || '';
const csrfToken = chatContainer.dataset.csrfToken || '';
const usernamesScript = document.getElementById('chat-usernames');
let allUsernames = [];
if (usernamesScript) {
@ -54,7 +55,9 @@
}
updateConnectionStatus('connecting');
ws = new WebSocket('ws://' + window.location.host + basePath + '/chat/?ws=true&id=' + boardId);
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const query = new URLSearchParams({ ws: 'true', id: boardId, csrf_token: csrfToken });
ws = new WebSocket(protocol + window.location.host + basePath + '/chat/?' + query.toString());
ws.onopen = function() {
updateConnectionStatus('connected');

View File

@ -5,6 +5,7 @@ function initLikeButtons() {
var postId = btn.getAttribute('data-post-id');
var type = btn.getAttribute('data-type');
var basePath = btn.getAttribute('data-base-path');
var csrfToken = document.body ? document.body.getAttribute('data-csrf-token') : '';
btn.disabled = true;
@ -14,7 +15,10 @@ function initLikeButtons() {
fetch(basePath + '/like/', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
},
body: body.toString()
})
.then(function(res) { return res.json(); })

View File

@ -40,6 +40,7 @@
<section>
<h3>Create New Thread</h3>
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="title">Thread Title:</label>
<input type="text" id="title" name="title" required maxlength="255"><br>
<input type="submit" value="Create Thread">

View File

@ -56,6 +56,7 @@
<section>
<h3>Create New Public Board</h3>
<form method="post" action="{{.BasePath}}/boards/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="name">Board Name:</label>
<input type="text" id="name" name="name" required maxlength="255"><br>
<label for="description">Description:</label>

View File

@ -12,10 +12,10 @@
<script src="{{.StaticPath}}/app.js" defer></script>
<script src="{{.StaticPath}}/chat.js" defer></script>
</head>
<body class="chat-page">
<body class="chat-page" data-csrf-token="{{.CSRFToken}}">
{{template "navbar" .}}
<main>
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}" data-csrf-token="{{.CSRFToken}}">
<div class="chat-breadcrumb">
<a href="{{.BasePath}}/">Home</a>
<span class="chat-breadcrumb-separator"></span>

View File

@ -17,6 +17,7 @@
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/login/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required autocomplete="username"><br>
<label for="password">Password:</label>

View File

@ -20,6 +20,7 @@
<p>{{.Content}}</p>
{{if $.IsAdmin}}
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" onclick="return confirm('Are you sure you want to delete this news item?')">Delete</button>
</form>
{{end}}
@ -34,6 +35,7 @@
<section>
<h3>Post New Announcement</h3>
<form method="post" action="{{.BasePath}}/news/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="title">Title:</label>
<input type="text" id="title" name="title" required maxlength="255"><br>
<label for="content">Content:</label>

View File

@ -11,6 +11,7 @@
{{end}}
<section>
<form method="post" action="{{.BasePath}}/preferences/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<h3>Draft Auto-Save</h3>
<label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
<input type="checkbox" id="auto_save_drafts" name="auto_save_drafts" {{if .Preferences.AutoSaveDrafts}}checked{{end}}>

View File

@ -14,10 +14,12 @@
</header>
<section>
<form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="display_name">Display Name:</label>
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
<label for="pfp">Profile Picture:</label>
<input type="file" id="pfp" name="pfp" accept="image/*"><br>
<input type="file" id="pfp" name="pfp" accept="image/png,image/jpeg,image/gif"><br>
<p style="margin-top: 0.25em; font-size: 0.9em; opacity: 0.8;">PNG, JPEG, or GIF only, up to 2 MB. Images are re-encoded before storage.</p>
<label for="bio">Bio:</label>
<textarea id="bio" name="bio" maxlength="500">{{.User.Bio}}</textarea><br>
<input type="submit" value="Save">

View File

@ -17,6 +17,7 @@
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
{{end}}
<form method="post" action="{{.BasePath}}/signup/">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
<label for="password">Password:</label>

View File

@ -7,7 +7,7 @@
<script src="{{.StaticPath}}/likes.js" defer></script>
<script src="{{.StaticPath}}/app.js" defer></script>
</head>
<body>
<body data-csrf-token="{{.CSRFToken}}">
{{template "navbar" .}}
<main>
<div class="breadcrumb">
@ -62,6 +62,7 @@
<button type="button" onclick="clearReply()">x</button>
</div>
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit" id="reply-form">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" id="reply-to-input" name="reply_to" value="">
<label for="content">Content:</label>
<textarea id="content" name="content" required></textarea><br>