Validation work
This commit is contained in:
@@ -25,16 +25,25 @@ const THREAD_COOLDOWN = 2 * time.Minute
|
|||||||
|
|
||||||
// const THREAD_COOLDOWN = 0 * time.Minute
|
// const THREAD_COOLDOWN = 0 * time.Minute
|
||||||
|
|
||||||
func IsOnCooldown(ip string, m map[string]time.Time, duration time.Duration) time.Duration {
|
func GetRemainingCooldown(ip string, m map[string]time.Time, duration time.Duration) time.Duration {
|
||||||
|
CooldownMutex.Lock()
|
||||||
|
defer CooldownMutex.Unlock()
|
||||||
|
|
||||||
|
last, exists := m[ip]
|
||||||
|
if !exists {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return duration - time.Since(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BeginCooldown(ip string, m map[string]time.Time, duration time.Duration) {
|
||||||
CooldownMutex.Lock()
|
CooldownMutex.Lock()
|
||||||
defer CooldownMutex.Unlock()
|
defer CooldownMutex.Unlock()
|
||||||
|
|
||||||
last, exists := m[ip]
|
last, exists := m[ip]
|
||||||
if !exists || time.Since(last) >= duration {
|
if !exists || time.Since(last) >= duration {
|
||||||
m[ip] = time.Now()
|
m[ip] = time.Now()
|
||||||
return time.Duration(0)
|
|
||||||
}
|
}
|
||||||
return duration - time.Since(last)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetIP(r *http.Request) string {
|
func GetIP(r *http.Request) string {
|
||||||
|
|||||||
+52
-15
@@ -12,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/disintegration/imaging"
|
||||||
@@ -19,11 +20,26 @@ import (
|
|||||||
|
|
||||||
// 10 MB memory limit
|
// 10 MB memory limit
|
||||||
const FILE_MEM_LIMIT int64 = 10 << 20
|
const FILE_MEM_LIMIT int64 = 10 << 20
|
||||||
|
const MAX_REQUEST_BYTES int64 = FILE_MEM_LIMIT + (1 << 20)
|
||||||
|
|
||||||
const POST_MEDIA_FULL_PATH = "web/static/media/posts/full"
|
const POST_MEDIA_FULL_PATH = "web/static/media/posts/full"
|
||||||
const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb"
|
const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb"
|
||||||
|
|
||||||
const MAX_THREAD_COUNT = 50
|
const MAX_THREAD_COUNT = 50
|
||||||
|
|
||||||
var SUPPORTED_VID_FORMATS = []string{".mp4", ".webm", ".ogg"}
|
const MAX_BODY_LEN = 3000
|
||||||
|
const MAX_SUBJECT_LEN = 100
|
||||||
|
|
||||||
|
var SUPPORTED_IMAGE_MIME_TYPES = []string{"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
|
var SUPPORTED_VIDEO_MIME_TYPES = []string{"video/webm", "video/mp4", "video/ogg"}
|
||||||
|
|
||||||
|
type PostMediaType int64
|
||||||
|
|
||||||
|
const (
|
||||||
|
PostFileImage PostMediaType = iota
|
||||||
|
PostFileVideo
|
||||||
|
PostFileUnsupported
|
||||||
|
)
|
||||||
|
|
||||||
func EnrichPost(body string) string {
|
func EnrichPost(body string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -62,27 +78,32 @@ func EnrichPost(body string) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsFileVideo(file multipart.File) (bool, error) {
|
func DetectPostFileType(file multipart.File) (PostMediaType, error) {
|
||||||
buffer := make([]byte, 512)
|
buffer := make([]byte, 512)
|
||||||
_, err := file.Read(buffer)
|
n, err := file.Read(buffer)
|
||||||
if err != nil {
|
if err != nil && err != io.EOF {
|
||||||
return false, err
|
return PostFileUnsupported, err
|
||||||
}
|
|
||||||
_, err = file.Seek(0, 0)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fileType := http.DetectContentType(buffer)
|
fileType := http.DetectContentType(buffer[:n])
|
||||||
|
|
||||||
return strings.HasPrefix(fileType, "video/"), nil
|
switch {
|
||||||
|
case slices.Contains(SUPPORTED_IMAGE_MIME_TYPES, fileType):
|
||||||
|
return PostFileImage, nil
|
||||||
|
case slices.Contains(SUPPORTED_VIDEO_MIME_TYPES, fileType):
|
||||||
|
return PostFileVideo, nil
|
||||||
|
default:
|
||||||
|
return PostFileUnsupported, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SavePostFile(file multipart.File, fileName string) (error, string, string) {
|
func SavePostFile(file multipart.File, fileName string) (error, string, string) {
|
||||||
isFileVideo, err := IsFileVideo(file)
|
mediaType, err := DetectPostFileType(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, "", ""
|
return err, "", ""
|
||||||
}
|
}
|
||||||
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
fileExt := strings.ToLower(filepath.Ext(fileName))
|
fileExt := strings.ToLower(filepath.Ext(fileName))
|
||||||
|
|
||||||
// FULL
|
// FULL
|
||||||
@@ -116,7 +137,7 @@ func SavePostFile(file multipart.File, fileName string) (error, string, string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var thumbFileName string
|
var thumbFileName string
|
||||||
if isFileVideo {
|
if mediaType == PostFileVideo {
|
||||||
fileNameNoExt := strings.TrimSuffix(fileName, fileExt)
|
fileNameNoExt := strings.TrimSuffix(fileName, fileExt)
|
||||||
thumbFileName = fileNameNoExt + ".jpg"
|
thumbFileName = fileNameNoExt + ".jpg"
|
||||||
|
|
||||||
@@ -188,10 +209,12 @@ func GetPostFileInfo(mediaPath string) PostFileInfo {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// read file type
|
// read file type
|
||||||
isFileVideo, _ := IsFileVideo(file)
|
mediaType, _ := DetectPostFileType(file)
|
||||||
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
result.Size = fileInfo.Size()
|
result.Size = fileInfo.Size()
|
||||||
|
|
||||||
if !isFileVideo {
|
if mediaType != PostFileVideo {
|
||||||
cfg, _, err := image.DecodeConfig(file)
|
cfg, _, err := image.DecodeConfig(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to decode image at %s: %v", mediaPath, err)
|
log.Printf("Failed to decode image at %s: %v", mediaPath, err)
|
||||||
@@ -238,3 +261,17 @@ func FormatFileInfo(fileInfo PostFileInfo) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("(%s, %dx%d)", humanSize, fileInfo.Width, fileInfo.Height)
|
return fmt.Sprintf("(%s, %dx%d)", humanSize, fileInfo.Width, fileInfo.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsMediaTooLarge(file multipart.File) (bool, error) {
|
||||||
|
limitedReader := io.LimitReader(file, FILE_MEM_LIMIT+1)
|
||||||
|
fileBytes, err := io.ReadAll(limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(fileBytes)) > FILE_MEM_LIMIT {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|||||||
+82
-19
@@ -8,7 +8,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -131,23 +130,42 @@ func main() {
|
|||||||
slug := chi.URLParam(r, "slug")
|
slug := chi.URLParam(r, "slug")
|
||||||
ip := util.GetIP(r)
|
ip := util.GetIP(r)
|
||||||
|
|
||||||
timeRemaining := util.IsOnCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN)
|
// check cooldown
|
||||||
|
timeRemaining := util.GetRemainingCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN)
|
||||||
if timeRemaining > 0 {
|
if timeRemaining > 0 {
|
||||||
response := fmt.Sprintf("Please wait %.0f seconds.", timeRemaining.Seconds())
|
response := fmt.Sprintf("Please wait %.0f seconds", timeRemaining.Seconds())
|
||||||
io.Copy(io.Discard, r.Body)
|
io.Copy(io.Discard, r.Body)
|
||||||
http.Error(w, response, http.StatusTooManyRequests)
|
http.Error(w, response, http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parse form
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, util.MAX_REQUEST_BYTES)
|
||||||
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
|
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
|
||||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||||
log.Printf("ParseMultipartForm: %v", err)
|
log.Printf("ParseMultipartForm: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := r.FormValue("subject")
|
// validate inputs
|
||||||
|
subject := strings.ReplaceAll(strings.TrimSpace(r.FormValue("subject")), "\n", "")
|
||||||
body := strings.TrimSpace(r.FormValue("body"))
|
body := strings.TrimSpace(r.FormValue("body"))
|
||||||
|
|
||||||
|
if len(subject) > util.MAX_SUBJECT_LEN {
|
||||||
|
http.Error(w, fmt.Sprintf("Subject exceeds %d characters", util.MAX_BODY_LEN), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if body == "" {
|
||||||
|
http.Error(w, "Body is empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) > util.MAX_BODY_LEN {
|
||||||
|
http.Error(w, fmt.Sprintf("Body exceeds %d characters", util.MAX_BODY_LEN), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to retrive file from form", http.StatusBadRequest)
|
http.Error(w, "Failed to retrive file from form", http.StatusBadRequest)
|
||||||
@@ -156,16 +174,32 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
isFileVideo, err := util.IsFileVideo(file)
|
if header.Size > util.FILE_MEM_LIMIT {
|
||||||
|
http.Error(w, "File too large (max 10 MB)", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMediaTooLarge, err := util.IsMediaTooLarge(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to detect if file is too large", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
|
if isMediaTooLarge {
|
||||||
|
http.Error(w, "File too large (max 10MB)", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, err := util.DetectPostFileType(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError)
|
http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
fileExt := strings.ToLower(filepath.Ext(header.Filename))
|
if mediaType == util.PostFileUnsupported {
|
||||||
|
http.Error(w, "Unsupported media type", http.StatusBadRequest)
|
||||||
if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) {
|
|
||||||
http.Error(w, "Unsupported file format", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +216,8 @@ func main() {
|
|||||||
log.Printf("PutThread: %v", err)
|
log.Printf("PutThread: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
util.BeginCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN)
|
||||||
})
|
})
|
||||||
|
|
||||||
// CREATE POST
|
// CREATE POST
|
||||||
@@ -189,26 +225,35 @@ func main() {
|
|||||||
slug := chi.URLParam(r, "slug")
|
slug := chi.URLParam(r, "slug")
|
||||||
ip := util.GetIP(r)
|
ip := util.GetIP(r)
|
||||||
|
|
||||||
timeRemaining := util.IsOnCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN)
|
// check cooldown
|
||||||
|
timeRemaining := util.GetRemainingCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN)
|
||||||
if timeRemaining > 0 {
|
if timeRemaining > 0 {
|
||||||
response := fmt.Sprintf("Please wait %.0f seconds.", timeRemaining.Seconds())
|
response := fmt.Sprintf("Please wait %.0f seconds", timeRemaining.Seconds())
|
||||||
io.Copy(io.Discard, r.Body)
|
io.Copy(io.Discard, r.Body)
|
||||||
http.Error(w, response, http.StatusTooManyRequests)
|
http.Error(w, response, http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parse form
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, util.MAX_REQUEST_BYTES)
|
||||||
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
|
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
|
||||||
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
http.Error(w, "Failed to parse form", http.StatusBadRequest)
|
||||||
log.Printf("ParseMultipartForm: %v", err)
|
log.Printf("ParseMultipartForm: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validate inputs
|
||||||
body := strings.TrimSpace(r.FormValue("body"))
|
body := strings.TrimSpace(r.FormValue("body"))
|
||||||
mediaPath := ""
|
mediaPath := ""
|
||||||
thumbPath := ""
|
thumbPath := ""
|
||||||
|
|
||||||
if body == "" {
|
if body == "" {
|
||||||
http.Error(w, "Malformed post body", http.StatusBadRequest)
|
http.Error(w, "Body is empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) > util.MAX_BODY_LEN {
|
||||||
|
http.Error(w, fmt.Sprintf("Body exceeds %d characters", util.MAX_BODY_LEN), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,21 +267,36 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// file type
|
if header.Size > util.FILE_MEM_LIMIT {
|
||||||
isFileVideo, err := util.IsFileVideo(file)
|
http.Error(w, "File too large (max 10 MB)", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMediaTooLarge, err := util.IsMediaTooLarge(file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to detect if file is too large", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
|
if isMediaTooLarge {
|
||||||
|
http.Error(w, "File too large (max 5MB)", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, err := util.DetectPostFileType(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError)
|
http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
file.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
fileExt := strings.ToLower(filepath.Ext(header.Filename))
|
if mediaType == util.PostFileUnsupported {
|
||||||
|
http.Error(w, "Unsupported media type", http.StatusBadRequest)
|
||||||
if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) {
|
|
||||||
http.Error(w, "Unsupported file format", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := strconv.FormatInt(time.Now().UnixNano(), 10) + fileExt
|
filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename)
|
||||||
err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename)
|
err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
@@ -247,6 +307,7 @@ func main() {
|
|||||||
thumbPath = savedThumbPath
|
thumbPath = savedThumbPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// put post into DB
|
||||||
threadIdStr := chi.URLParam(r, "threadId")
|
threadIdStr := chi.URLParam(r, "threadId")
|
||||||
threadId, err := strconv.Atoi(threadIdStr)
|
threadId, err := strconv.Atoi(threadIdStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -259,6 +320,8 @@ func main() {
|
|||||||
log.Printf("PutPost: %v", err)
|
log.Printf("PutPost: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
util.BeginCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN)
|
||||||
})
|
})
|
||||||
|
|
||||||
// -----------------
|
// -----------------
|
||||||
|
|||||||
@@ -132,8 +132,7 @@ body {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#newPostForm button,
|
#newPostForm button {
|
||||||
#newThreadForm button {
|
|
||||||
display: block;
|
display: block;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ function isOffScreen(el) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHttpWarningStatus(code) {
|
||||||
|
const warningStatuses = [429, 400, 413];
|
||||||
|
return warningStatuses.includes(code);
|
||||||
|
}
|
||||||
|
|
||||||
function insertAfter(referenceNode, newNode) {
|
function insertAfter(referenceNode, newNode) {
|
||||||
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-55
@@ -8,58 +8,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
templ NewThreadForm(board database.Board) {
|
|
||||||
<div style="display: none;" id="newPostWarning" class="warning"></div>
|
|
||||||
<form
|
|
||||||
hx-encoding="multipart/form-data"
|
|
||||||
style="display: none;"
|
|
||||||
id="newThreadForm"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-post={ fmt.Sprintf("/%s/threads", board.Slug) }
|
|
||||||
_="
|
|
||||||
on htmx:beforeSend toggle @disabled on <button/> until htmx:afterRequest
|
|
||||||
on htmx:afterRequest
|
|
||||||
if event.detail.xhr.status is not 429
|
|
||||||
hide me
|
|
||||||
then show #newThreadBtn
|
|
||||||
then set #newPostBody.value to ''
|
|
||||||
then set #newPostFile.value to ''
|
|
||||||
then set #newPostSubject.value to ''
|
|
||||||
then hide #newPostWarning
|
|
||||||
then trigger refreshThreads on body
|
|
||||||
else
|
|
||||||
show #newPostWarning
|
|
||||||
then put event.detail.xhr.responseText into #newPostWarning
|
|
||||||
end"
|
|
||||||
>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr class="new-post-form-field">
|
|
||||||
<th>Subject</th>
|
|
||||||
<td><input id="newPostSubject" name="subject"/></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="new-post-form-field">
|
|
||||||
<th>Comment</th>
|
|
||||||
<td><textarea id="newPostBody" name="body" required></textarea></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="new-post-form-field">
|
|
||||||
<th>File</th>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
accept="image/*,video/mp4,video/webm,video/ogg"
|
|
||||||
id="newPostFile"
|
|
||||||
name="file"
|
|
||||||
type="file"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<button type="submit">Submit</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ BoardHeader(board database.Board) {
|
templ BoardHeader(board database.Board) {
|
||||||
<header class="board-header">
|
<header class="board-header">
|
||||||
<img src={ fmt.Sprintf("/static/media/banners/%s.png", board.Slug) }/>
|
<img src={ fmt.Sprintf("/static/media/banners/%s.png", board.Slug) }/>
|
||||||
@@ -74,8 +22,12 @@ templ Board(board database.Board) {
|
|||||||
@shared.Layout() {
|
@shared.Layout() {
|
||||||
@BoardHeader(board)
|
@BoardHeader(board)
|
||||||
<div class="new-post-container">
|
<div class="new-post-container">
|
||||||
<button _="on click hide me then show #newThreadForm" id="newThreadBtn">[New Thread]</button>
|
<button
|
||||||
@NewThreadForm(board)
|
_="on load hide #newPostForm
|
||||||
|
on click hide me then show #newPostForm"
|
||||||
|
id="newThreadBtn"
|
||||||
|
>[New Thread]</button>
|
||||||
|
@shared.NewPostForm(board, fmt.Sprintf("/%s/threads", board.Slug), true)
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div id="boardActionBar">
|
<div id="boardActionBar">
|
||||||
@@ -89,7 +41,7 @@ templ Board(board database.Board) {
|
|||||||
<hr/>
|
<hr/>
|
||||||
<div
|
<div
|
||||||
hx-get={ fmt.Sprintf("/hx/%s/catalog", board.Slug) }
|
hx-get={ fmt.Sprintf("/hx/%s/catalog", board.Slug) }
|
||||||
hx-trigger="load, refreshThreads from:body"
|
hx-trigger="load, refreshPosts from:body"
|
||||||
_="on htmx:afterRequest
|
_="on htmx:afterRequest
|
||||||
call resizeCatalogPreviewImgs()
|
call resizeCatalogPreviewImgs()
|
||||||
then call applyCatalogSearch()"
|
then call applyCatalogSearch()"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import "github.com/dominicf2001/comfychan/internal/database"
|
||||||
|
import "strings"
|
||||||
|
import "github.com/dominicf2001/comfychan/internal/util"
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
templ NewPostForm(board database.Board, endpoint string, isForThread bool) {
|
||||||
|
<div style="display: none;" id="newPostWarning" class="warning"></div>
|
||||||
|
<form
|
||||||
|
hx-encoding="multipart/form-data"
|
||||||
|
id="newPostForm"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-post={ endpoint }
|
||||||
|
_="
|
||||||
|
on htmx:beforeRequest toggle @disabled on <button/> until htmx:afterRequest
|
||||||
|
|
||||||
|
on htmx:afterRequest
|
||||||
|
if isHttpWarningStatus(event.detail.xhr.status)
|
||||||
|
show #newPostWarning
|
||||||
|
put event.detail.xhr.responseText into #newPostWarning
|
||||||
|
else
|
||||||
|
hide #newPostWarning
|
||||||
|
set #newPostBody.value to ''
|
||||||
|
set #newPostFile.value to ''
|
||||||
|
set #newPostSubject.value to ''
|
||||||
|
trigger refreshPosts on body
|
||||||
|
end
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<table>
|
||||||
|
{{ acceptedMimeTypes := strings.Join(util.SUPPORTED_IMAGE_MIME_TYPES, ",") + "," + strings.Join(util.SUPPORTED_VIDEO_MIME_TYPES, ",") }}
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
if !isForThread {
|
||||||
|
style="display: none;"
|
||||||
|
}
|
||||||
|
class="new-post-form-field"
|
||||||
|
>
|
||||||
|
<th>Subject</th>
|
||||||
|
<td><input maxlength={ strconv.Itoa(util.MAX_SUBJECT_LEN) } id="newPostSubject" name="subject"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="new-post-form-field">
|
||||||
|
<th>Comment</th>
|
||||||
|
<td><textarea maxlength={ strconv.Itoa(util.MAX_BODY_LEN) } id="newPostBody" name="body" required></textarea></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="new-post-form-field">
|
||||||
|
<th>File</th>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
accept={ acceptedMimeTypes }
|
||||||
|
id="newPostFile"
|
||||||
|
name="file"
|
||||||
|
type="file"
|
||||||
|
if isForThread {
|
||||||
|
required
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
+1
-40
@@ -117,46 +117,7 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post
|
|||||||
<script src="/static/thread.js" defer></script>
|
<script src="/static/thread.js" defer></script>
|
||||||
@BoardHeader(board)
|
@BoardHeader(board)
|
||||||
<div class="new-post-container">
|
<div class="new-post-container">
|
||||||
<div style="display: none;" id="newPostWarning" class="warning"></div>
|
@shared.NewPostForm(board, fmt.Sprintf("/%s/threads/%d", board.Slug, thread.Id), false)
|
||||||
<form
|
|
||||||
hx-encoding="multipart/form-data"
|
|
||||||
id="newPostForm"
|
|
||||||
hx-target="#newPostWarning"
|
|
||||||
hx-post={ fmt.Sprintf("/%s/threads/%d", board.Slug, thread.Id) }
|
|
||||||
_="
|
|
||||||
on htmx:beforeSend toggle @disabled on <button/> until htmx:afterRequest
|
|
||||||
on htmx:afterRequest
|
|
||||||
if event.detail.xhr.status is not 429
|
|
||||||
set #newPostBody.value to ''
|
|
||||||
then set #newPostFile.value to ''
|
|
||||||
then hide #newPostWarning
|
|
||||||
then trigger refreshPosts on body
|
|
||||||
else
|
|
||||||
show #newPostWarning
|
|
||||||
then put event.detail.xhr.responseText into #newPostWarning
|
|
||||||
end"
|
|
||||||
>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr class="new-post-form-field">
|
|
||||||
<th>comment</th>
|
|
||||||
<td><textarea id="newPostBody" name="body" required></textarea></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="new-post-form-field">
|
|
||||||
<th>File</th>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
accept="image/*,video/mp4,video/webm,video/ogg"
|
|
||||||
id="newPostFile"
|
|
||||||
name="file"
|
|
||||||
type="file"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<button type="submit">Submit</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<hr/>
|
<hr/>
|
||||||
<div style="margin-left: 25px;">
|
<div style="margin-left: 25px;">
|
||||||
|
|||||||
Reference in New Issue
Block a user