Compare commits
No commits in common. "8ff0b7f2c25d82069f5185c21d6b47f8c8010010" and "91c7591c1948072d023fc3711c1cfba41228d523" have entirely different histories.
8ff0b7f2c2
...
91c7591c19
|
|
@ -20,7 +20,6 @@ type PageData struct {
|
||||||
CurrentURL string
|
CurrentURL string
|
||||||
ContentTemplate string
|
ContentTemplate string
|
||||||
BodyClass string
|
BodyClass string
|
||||||
CSRFToken string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
|
||||||
|
|
@ -58,11 +58,6 @@ func BoardHandler(app *App) http.HandlerFunc {
|
||||||
if r.Method == http.MethodPost && loggedIn {
|
if r.Method == http.MethodPost && loggedIn {
|
||||||
action := r.URL.Query().Get("action")
|
action := r.URL.Query().Get("action")
|
||||||
if action == "create_thread" {
|
if action == "create_thread" {
|
||||||
if !app.validateCSRFToken(r, session) {
|
|
||||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
title := r.FormValue("title")
|
title := r.FormValue("title")
|
||||||
if title == "" {
|
if title == "" {
|
||||||
http.Error(w, "Thread title is required", http.StatusBadRequest)
|
http.Error(w, "Thread title is required", http.StatusBadRequest)
|
||||||
|
|
@ -123,7 +118,6 @@ func BoardHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Board: *board,
|
Board: *board,
|
||||||
Threads: threads,
|
Threads: threads,
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,6 @@ func BoardsHandler(app *App) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost && loggedIn && isAdmin {
|
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")
|
name := r.FormValue("name")
|
||||||
description := r.FormValue("description")
|
description := r.FormValue("description")
|
||||||
boardType := r.FormValue("type")
|
boardType := r.FormValue("type")
|
||||||
|
|
@ -111,7 +106,6 @@ func BoardsHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
PublicBoards: publicBoards,
|
PublicBoards: publicBoards,
|
||||||
PrivateBoards: privateBoards,
|
PrivateBoards: privateBoards,
|
||||||
|
|
|
||||||
|
|
@ -147,11 +147,6 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
currentUsername := currentUser.Username
|
currentUsername := currentUser.Username
|
||||||
|
|
||||||
if r.URL.Query().Get("ws") == "true" {
|
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)
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error upgrading to WebSocket: %v", err)
|
log.Printf("Error upgrading to WebSocket: %v", err)
|
||||||
|
|
@ -245,7 +240,6 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
ContentTemplate: "chat-content",
|
ContentTemplate: "chat-content",
|
||||||
BodyClass: "chat-page",
|
BodyClass: "chat-page",
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Board: *board,
|
Board: *board,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"threadr/models"
|
"threadr/models"
|
||||||
)
|
)
|
||||||
|
|
@ -21,21 +23,10 @@ func FileHandler(app *App) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isProfileImage, err := models.IsProfileImageFile(app.DB, fileID)
|
fileExt := filepath.Ext(file.OriginalName)
|
||||||
if err != nil || !isProfileImage {
|
fileName := fmt.Sprintf("%d%s", fileID, fileExt)
|
||||||
http.NotFound(w, r)
|
filePath := filepath.Join(app.Config.FileStorageDir, fileName)
|
||||||
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)
|
http.ServeFile(w, r, filePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,6 @@ func LikeHandler(app *App) http.HandlerFunc {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !app.validateCSRFToken(r, session) {
|
|
||||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
postIDStr := r.FormValue("post_id")
|
postIDStr := r.FormValue("post_id")
|
||||||
postID, err := strconv.Atoi(postIDStr)
|
postID, err := strconv.Atoi(postIDStr)
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@ func LoginHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
if !app.validateCSRFToken(r, session) {
|
|
||||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
user, err := models.GetUserByUsername(app.DB, username)
|
user, err := models.GetUserByUsername(app.DB, username)
|
||||||
|
|
@ -58,7 +53,6 @@ func LoginHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Error: "",
|
Error: "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,6 @@ func NewsHandler(app *App) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost && loggedIn && isAdmin {
|
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" {
|
if action := r.URL.Query().Get("action"); action == "delete" {
|
||||||
newsIDStr := r.URL.Query().Get("id")
|
newsIDStr := r.URL.Query().Get("id")
|
||||||
newsID, err := strconv.Atoi(newsIDStr)
|
newsID, err := strconv.Atoi(newsIDStr)
|
||||||
|
|
@ -90,7 +85,6 @@ func NewsHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
News: newsItems,
|
News: newsItems,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,6 @@ func PreferencesHandler(app *App) http.HandlerFunc {
|
||||||
|
|
||||||
// Handle POST request (saving preferences)
|
// Handle POST request (saving preferences)
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
if !app.validateCSRFToken(r, session) {
|
|
||||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get form values
|
// Get form values
|
||||||
autoSaveDrafts := r.FormValue("auto_save_drafts") == "on"
|
autoSaveDrafts := r.FormValue("auto_save_drafts") == "on"
|
||||||
|
|
||||||
|
|
@ -75,7 +70,6 @@ func PreferencesHandler(app *App) http.HandlerFunc {
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
ContentTemplate: "preferences-content",
|
ContentTemplate: "preferences-content",
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Preferences: prefs,
|
Preferences: prefs,
|
||||||
ShowSuccess: showSuccess,
|
ShowSuccess: showSuccess,
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,18 @@
|
||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
|
||||||
_ "image/gif"
|
|
||||||
_ "image/jpeg"
|
|
||||||
"image/png"
|
|
||||||
_ "image/png"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"threadr/models"
|
"threadr/models"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxProfileImageBytes = 2 << 20
|
|
||||||
|
|
||||||
func ProfileEditHandler(app *App) http.HandlerFunc {
|
func ProfileEditHandler(app *App) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
|
|
@ -34,70 +23,62 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == http.MethodPost {
|
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
|
// Handle file upload
|
||||||
file, handler, err := r.FormFile("pfp")
|
file, handler, err := r.FormFile("pfp")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
fileHash, fileID, err := saveProfileImageUpload(app, file)
|
// Create a hash of the file
|
||||||
if err != nil {
|
h := sha256.New()
|
||||||
if errors.Is(err, errInvalidProfileImage) || errors.Is(err, errProfileImageTooLarge) {
|
if _, err := io.Copy(h, file); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
log.Printf("Error hashing file: %v", err)
|
||||||
return
|
http.Error(w, "Failed to process file", http.StatusInternalServerError)
|
||||||
}
|
|
||||||
log.Printf("Error saving profile image: %v", err)
|
|
||||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fileHash := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
|
||||||
// Create file record in the database
|
// Create file record in the database
|
||||||
fileRecord := models.File{
|
fileRecord := models.File{
|
||||||
OriginalName: sanitizeOriginalFileName(handler.Filename),
|
OriginalName: handler.Filename,
|
||||||
Hash: fileHash,
|
Hash: fileHash,
|
||||||
HashAlgorithm: "sha256",
|
HashAlgorithm: "sha256",
|
||||||
}
|
}
|
||||||
createdFileID, err := models.CreateFile(app.DB, fileRecord)
|
fileID, err := models.CreateFile(app.DB, fileRecord)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating file record: %v", err)
|
log.Printf("Error creating file record: %v", err)
|
||||||
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
|
http.Error(w, "Failed to save file information", http.StatusInternalServerError)
|
||||||
_ = os.Remove(fileID)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
finalPath := filepath.Join(app.Config.FileStorageDir, models.ProfileImageStorageName(createdFileID))
|
// Save the file to disk
|
||||||
if err := os.Rename(fileID, finalPath); err != nil {
|
fileExt := filepath.Ext(handler.Filename)
|
||||||
_ = os.Remove(fileID)
|
newFileName := fmt.Sprintf("%d%s", fileID, fileExt)
|
||||||
_ = models.DeleteFileByID(app.DB, createdFileID)
|
filePath := filepath.Join(app.Config.FileStorageDir, newFileName)
|
||||||
log.Printf("Error moving file on disk: %v", err)
|
|
||||||
|
// Reset file pointer
|
||||||
|
file.Seek(0, 0)
|
||||||
|
|
||||||
|
dst, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error creating file on disk: %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)
|
||||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update user's pfp_file_id
|
// Update user's pfp_file_id
|
||||||
err = models.UpdateUserPfp(app.DB, userID, createdFileID)
|
err = models.UpdateUserPfp(app.DB, userID, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(finalPath)
|
|
||||||
_ = models.DeleteFileByID(app.DB, createdFileID)
|
|
||||||
log.Printf("Error updating user pfp: %v", err)
|
log.Printf("Error updating user pfp: %v", err)
|
||||||
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
http.Error(w, "Failed to update profile", http.StatusInternalServerError)
|
||||||
return
|
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
|
// Update other profile fields
|
||||||
|
|
@ -137,7 +118,6 @@ func ProfileEditHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
User: *user,
|
User: *user,
|
||||||
}
|
}
|
||||||
|
|
@ -148,67 +128,3 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,6 @@ func SignupHandler(app *App) http.HandlerFunc {
|
||||||
session := r.Context().Value("session").(*sessions.Session)
|
session := r.Context().Value("session").(*sessions.Session)
|
||||||
cookie, _ := r.Cookie("threadr_cookie_banner")
|
cookie, _ := r.Cookie("threadr_cookie_banner")
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
if !app.validateCSRFToken(r, session) {
|
|
||||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
passwordConfirm := r.FormValue("password_confirm")
|
passwordConfirm := r.FormValue("password_confirm")
|
||||||
|
|
@ -36,7 +31,6 @@ func SignupHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Error: "Passwords do not match. Please try again.",
|
Error: "Passwords do not match. Please try again.",
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +57,6 @@ func SignupHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Error: "An error occurred during sign up. Please try again.",
|
Error: "An error occurred during sign up. Please try again.",
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +82,6 @@ func SignupHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Error: "",
|
Error: "",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,6 @@ func ThreadHandler(app *App) http.HandlerFunc {
|
||||||
if r.Method == http.MethodPost && loggedIn {
|
if r.Method == http.MethodPost && loggedIn {
|
||||||
action := r.URL.Query().Get("action")
|
action := r.URL.Query().Get("action")
|
||||||
if action == "submit" {
|
if action == "submit" {
|
||||||
if !app.validateCSRFToken(r, session) {
|
|
||||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
content := r.FormValue("content")
|
content := r.FormValue("content")
|
||||||
replyToStr := r.FormValue("reply_to")
|
replyToStr := r.FormValue("reply_to")
|
||||||
if replyToStr == "" {
|
if replyToStr == "" {
|
||||||
|
|
@ -169,7 +164,6 @@ func ThreadHandler(app *App) http.HandlerFunc {
|
||||||
BasePath: app.Config.ThreadrDir,
|
BasePath: app.Config.ThreadrDir,
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.RequestURI(),
|
CurrentURL: r.URL.RequestURI(),
|
||||||
CSRFToken: app.csrfToken(session),
|
|
||||||
},
|
},
|
||||||
Thread: *thread,
|
Thread: *thread,
|
||||||
Board: *board,
|
Board: *board,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,8 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const ProfileImageExtension = ".png"
|
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID int
|
ID int
|
||||||
OriginalName string
|
OriginalName string
|
||||||
|
|
@ -31,15 +25,6 @@ func GetFileByID(db *sql.DB, id int64) (*File, error) {
|
||||||
return file, nil
|
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) {
|
func CreateFile(db *sql.DB, file File) (int64, error) {
|
||||||
query := "INSERT INTO files (original_name, hash, hash_algorithm) VALUES (?, ?, ?)"
|
query := "INSERT INTO files (original_name, hash, hash_algorithm) VALUES (?, ?, ?)"
|
||||||
result, err := db.Exec(query, file.OriginalName, file.Hash, file.HashAlgorithm)
|
result, err := db.Exec(query, file.OriginalName, file.Hash, file.HashAlgorithm)
|
||||||
|
|
@ -48,58 +33,3 @@ func CreateFile(db *sql.DB, file File) (int64, error) {
|
||||||
}
|
}
|
||||||
return result.LastInsertId()
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
const boardId = chatContainer.dataset.boardId;
|
const boardId = chatContainer.dataset.boardId;
|
||||||
const basePath = chatContainer.dataset.basePath || '';
|
const basePath = chatContainer.dataset.basePath || '';
|
||||||
const currentUsername = chatContainer.dataset.currentUsername || '';
|
const currentUsername = chatContainer.dataset.currentUsername || '';
|
||||||
const csrfToken = chatContainer.dataset.csrfToken || '';
|
|
||||||
const usernamesScript = document.getElementById('chat-usernames');
|
const usernamesScript = document.getElementById('chat-usernames');
|
||||||
let allUsernames = [];
|
let allUsernames = [];
|
||||||
if (usernamesScript) {
|
if (usernamesScript) {
|
||||||
|
|
@ -55,9 +54,7 @@
|
||||||
}
|
}
|
||||||
updateConnectionStatus('connecting');
|
updateConnectionStatus('connecting');
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
ws = new WebSocket('ws://' + window.location.host + basePath + '/chat/?ws=true&id=' + boardId);
|
||||||
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() {
|
ws.onopen = function() {
|
||||||
updateConnectionStatus('connected');
|
updateConnectionStatus('connected');
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ function initLikeButtons() {
|
||||||
var postId = btn.getAttribute('data-post-id');
|
var postId = btn.getAttribute('data-post-id');
|
||||||
var type = btn.getAttribute('data-type');
|
var type = btn.getAttribute('data-type');
|
||||||
var basePath = btn.getAttribute('data-base-path');
|
var basePath = btn.getAttribute('data-base-path');
|
||||||
var csrfToken = document.body ? document.body.getAttribute('data-csrf-token') : '';
|
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
|
|
@ -15,10 +14,7 @@ function initLikeButtons() {
|
||||||
|
|
||||||
fetch(basePath + '/like/', {
|
fetch(basePath + '/like/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
'X-CSRF-Token': csrfToken
|
|
||||||
},
|
|
||||||
body: body.toString()
|
body: body.toString()
|
||||||
})
|
})
|
||||||
.then(function(res) { return res.json(); })
|
.then(function(res) { return res.json(); })
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@
|
||||||
<section>
|
<section>
|
||||||
<h3>Create New Thread</h3>
|
<h3>Create New Thread</h3>
|
||||||
<form method="post" action="{{.BasePath}}/board/?id={{.Board.ID}}&action=create_thread">
|
<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>
|
<label for="title">Thread Title:</label>
|
||||||
<input type="text" id="title" name="title" required maxlength="255"><br>
|
<input type="text" id="title" name="title" required maxlength="255"><br>
|
||||||
<input type="submit" value="Create Thread">
|
<input type="submit" value="Create Thread">
|
||||||
|
|
@ -51,4 +50,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -56,7 +56,6 @@
|
||||||
<section>
|
<section>
|
||||||
<h3>Create New Public Board</h3>
|
<h3>Create New Public Board</h3>
|
||||||
<form method="post" action="{{.BasePath}}/boards/">
|
<form method="post" action="{{.BasePath}}/boards/">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<label for="name">Board Name:</label>
|
<label for="name">Board Name:</label>
|
||||||
<input type="text" id="name" name="name" required maxlength="255"><br>
|
<input type="text" id="name" name="name" required maxlength="255"><br>
|
||||||
<label for="description">Description:</label>
|
<label for="description">Description:</label>
|
||||||
|
|
@ -74,4 +73,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -12,10 +12,10 @@
|
||||||
<script src="{{.StaticPath}}/app.js" defer></script>
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
<script src="{{.StaticPath}}/chat.js" defer></script>
|
<script src="{{.StaticPath}}/chat.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="chat-page" data-csrf-token="{{.CSRFToken}}">
|
<body class="chat-page">
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}" data-csrf-token="{{.CSRFToken}}">
|
<div class="chat-container" data-board-id="{{.Board.ID}}" data-base-path="{{.BasePath}}" data-current-username="{{.CurrentUsername}}">
|
||||||
<div class="chat-breadcrumb">
|
<div class="chat-breadcrumb">
|
||||||
<a href="{{.BasePath}}/">Home</a>
|
<a href="{{.BasePath}}/">Home</a>
|
||||||
<span class="chat-breadcrumb-separator">›</span>
|
<span class="chat-breadcrumb-separator">›</span>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
|
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="post" action="{{.BasePath}}/login/">
|
<form method="post" action="{{.BasePath}}/login/">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
<input type="text" id="username" name="username" required autocomplete="username"><br>
|
<input type="text" id="username" name="username" required autocomplete="username"><br>
|
||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
|
|
@ -29,4 +28,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
<p>{{.Content}}</p>
|
<p>{{.Content}}</p>
|
||||||
{{if $.IsAdmin}}
|
{{if $.IsAdmin}}
|
||||||
<form method="post" action="{{$.BasePath}}/news/?action=delete&id={{.ID}}" style="display:inline;">
|
<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>
|
<button type="submit" onclick="return confirm('Are you sure you want to delete this news item?')">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -35,7 +34,6 @@
|
||||||
<section>
|
<section>
|
||||||
<h3>Post New Announcement</h3>
|
<h3>Post New Announcement</h3>
|
||||||
<form method="post" action="{{.BasePath}}/news/">
|
<form method="post" action="{{.BasePath}}/news/">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<label for="title">Title:</label>
|
<label for="title">Title:</label>
|
||||||
<input type="text" id="title" name="title" required maxlength="255"><br>
|
<input type="text" id="title" name="title" required maxlength="255"><br>
|
||||||
<label for="content">Content:</label>
|
<label for="content">Content:</label>
|
||||||
|
|
@ -48,4 +46,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<section>
|
<section>
|
||||||
<form method="post" action="{{.BasePath}}/preferences/">
|
<form method="post" action="{{.BasePath}}/preferences/">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<h3>Draft Auto-Save</h3>
|
<h3>Draft Auto-Save</h3>
|
||||||
<label for="auto_save_drafts" style="display: flex; align-items: center; gap: 0.5em; cursor: pointer;">
|
<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}}>
|
<input type="checkbox" id="auto_save_drafts" name="auto_save_drafts" {{if .Preferences.AutoSaveDrafts}}checked{{end}}>
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,10 @@
|
||||||
</header>
|
</header>
|
||||||
<section>
|
<section>
|
||||||
<form method="post" action="{{.BasePath}}/profile/edit/" enctype="multipart/form-data">
|
<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>
|
<label for="display_name">Display Name:</label>
|
||||||
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
|
<input type="text" id="display_name" name="display_name" value="{{.User.DisplayName}}" maxlength="255"><br>
|
||||||
<label for="pfp">Profile Picture:</label>
|
<label for="pfp">Profile Picture:</label>
|
||||||
<input type="file" id="pfp" name="pfp" accept="image/png,image/jpeg,image/gif"><br>
|
<input type="file" id="pfp" name="pfp" accept="image/*"><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>
|
<label for="bio">Bio:</label>
|
||||||
<textarea id="bio" name="bio" maxlength="500">{{.User.Bio}}</textarea><br>
|
<textarea id="bio" name="bio" maxlength="500">{{.User.Bio}}</textarea><br>
|
||||||
<input type="submit" value="Save">
|
<input type="submit" value="Save">
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
|
<p class="field-error" style="text-align: center; font-size: 1em;">{{.Error}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="post" action="{{.BasePath}}/signup/">
|
<form method="post" action="{{.BasePath}}/signup/">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
|
||||||
<label for="username">Username:</label>
|
<label for="username">Username:</label>
|
||||||
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
|
<input type="text" id="username" name="username" required autocomplete="username" minlength="3" maxlength="30"><br>
|
||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
|
|
@ -31,4 +30,4 @@
|
||||||
{{template "cookie_banner" .}}
|
{{template "cookie_banner" .}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<script src="{{.StaticPath}}/likes.js" defer></script>
|
<script src="{{.StaticPath}}/likes.js" defer></script>
|
||||||
<script src="{{.StaticPath}}/app.js" defer></script>
|
<script src="{{.StaticPath}}/app.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body data-csrf-token="{{.CSRFToken}}">
|
<body>
|
||||||
{{template "navbar" .}}
|
{{template "navbar" .}}
|
||||||
<main>
|
<main>
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
|
|
@ -62,7 +62,6 @@
|
||||||
<button type="button" onclick="clearReply()">x</button>
|
<button type="button" onclick="clearReply()">x</button>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{.BasePath}}/thread/?id={{.Thread.ID}}&action=submit" id="reply-form">
|
<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="">
|
<input type="hidden" id="reply-to-input" name="reply_to" value="">
|
||||||
<label for="content">Content:</label>
|
<label for="content">Content:</label>
|
||||||
<textarea id="content" name="content" required></textarea><br>
|
<textarea id="content" name="content" required></textarea><br>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue