{ thread.Subject } -
@@ -61,25 +62,26 @@ templ PostReply(post database.Post) {@@ -128,7 +130,14 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post
diff --git a/.gitignore b/.gitignore index 22f3670..df712a9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ internal/database/comfychan.db **/*_templ.go tmp -web/static/img/posts/ -web/static/img/posts/ +web/static/media/posts/ +web/static/media/posts/ diff --git a/Makefile b/Makefile index a7b85b4..c038ea8 100644 --- a/Makefile +++ b/Makefile @@ -20,4 +20,4 @@ db/seed/f: rm ./internal/database/comfychan.db && sqlite3 ./internal/database/comfychan.db < ./internal/database/seed.sql img/clear: - rm -rf ./web/static/img/posts || true + rm -rf ./web/static/media/posts || true diff --git a/internal/database/handlers.go b/internal/database/handlers.go index f13ac37..f1703a3 100644 --- a/internal/database/handlers.go +++ b/internal/database/handlers.go @@ -93,7 +93,7 @@ func GetThread(db *sql.DB, threadId int) (Thread, error) { func GetPosts(db *sql.DB, threadId int) ([]Post, error) { rows, err := db.Query(` - SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number + SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number, thumb_path FROM posts WHERE thread_id = ?`, threadId) @@ -105,7 +105,7 @@ func GetPosts(db *sql.DB, threadId int) ([]Post, error) { var result []Post for rows.Next() { var p Post - err := rows.Scan(&p.Id, &p.ThreadId, &p.Author, &p.Body, &p.CreatedAt, &p.MediaPath, &p.IpHash, &p.Number) + err := rows.Scan(&p.Id, &p.ThreadId, &p.Author, &p.Body, &p.CreatedAt, &p.MediaPath, &p.IpHash, &p.Number, &p.ThumbPath) if err != nil { return nil, err } @@ -117,20 +117,20 @@ func GetPosts(db *sql.DB, threadId int) ([]Post, error) { func GetOriginalPost(db *sql.DB, threadId int) (Post, error) { row := db.QueryRow(` - SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number + SELECT id, thread_id, author, body, created_at, media_path, ip_hash, number, thumb_path FROM posts WHERE thread_id = ? ORDER BY created_at ASC LIMIT 1`, threadId) var r Post - err := row.Scan(&r.Id, &r.ThreadId, &r.Author, &r.Body, &r.CreatedAt, &r.MediaPath, &r.IpHash, &r.Number) + err := row.Scan(&r.Id, &r.ThreadId, &r.Author, &r.Body, &r.CreatedAt, &r.MediaPath, &r.IpHash, &r.Number, &r.ThumbPath) if err != nil { return Post{}, err } return r, row.Err() } -func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaPath string, ip_hash string) error { +func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaPath string, thumbPath string, ip_hash string) error { tx, err := db.Begin() if err != nil { return err @@ -150,7 +150,7 @@ func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaP return err } - if err := PutPost(tx, boardSlug, int(threadId), body, mediaPath, ip_hash); err != nil { + if err := PutPost(tx, boardSlug, int(threadId), body, mediaPath, thumbPath, ip_hash); err != nil { return err } @@ -188,7 +188,7 @@ func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaP return nil } -func PutPost(db Queryer, boardSlug string, threadId int, body string, mediaPath string, ip_hash string) error { +func PutPost(db Queryer, boardSlug string, threadId int, body string, mediaPath string, thumbPath string, ip_hash string) error { row := db.QueryRow(` SELECT MAX(p.number) FROM posts p @@ -206,8 +206,8 @@ func PutPost(db Queryer, boardSlug string, threadId int, body string, mediaPath } _, err := db.Exec(` - INSERT INTO posts (thread_id, body, media_path, ip_hash, number) - VALUES (?, ?, ?, ?, ?)`, threadId, body, mediaPath, ip_hash, newPostNumber) + INSERT INTO posts (thread_id, body, media_path, ip_hash, number, thumb_path) + VALUES (?, ?, ?, ?, ?, ?)`, threadId, body, mediaPath, ip_hash, newPostNumber, thumbPath) if err != nil { return err } @@ -241,10 +241,10 @@ func DeleteThread(db Queryer, threadId int) error { } for _, pruneMediaPath := range pruneMediaPaths { - if err := os.Remove(path.Join(util.POST_IMG_FULL_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) { + if err := os.Remove(path.Join(util.POST_MEDIA_FULL_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) { return err } - if err := os.Remove(path.Join(util.POST_IMG_THUMB_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) { + if err := os.Remove(path.Join(util.POST_MEDIA_THUMB_PATH, pruneMediaPath)); err != nil && !os.IsNotExist(err) { return err } } diff --git a/internal/database/models.go b/internal/database/models.go index 01b9489..514f5e4 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -24,6 +24,7 @@ type Post struct { Body string CreatedAt time.Time MediaPath string + ThumbPath string IpHash string Number int } diff --git a/internal/database/seed.sql b/internal/database/seed.sql index 2282c03..bd25f47 100644 --- a/internal/database/seed.sql +++ b/internal/database/seed.sql @@ -23,11 +23,12 @@ CREATE TABLE IF NOT EXISTS threads ( CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, - number INTEGER NOT NULL, + number INTEGER NOT NULL , author TEXT DEFAULT 'Anonymous', body TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, media_path TEXT NOT NULL DEFAULT '', + thumb_path TEXT NOT NULL DEFAULT '', ip_hash TEXT NOT NULL, FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE ); diff --git a/internal/util/posts.go b/internal/util/posts.go index 3b57256..9a05c66 100644 --- a/internal/util/posts.go +++ b/internal/util/posts.go @@ -7,7 +7,9 @@ import ( "io" "log" "mime/multipart" + "net/http" "os" + "os/exec" "path" "path/filepath" "strings" @@ -17,10 +19,12 @@ import ( // 10 MB memory limit const FILE_MEM_LIMIT int64 = 10 << 20 -const POST_IMG_FULL_PATH = "web/static/img/posts/full" -const POST_IMG_THUMB_PATH = "web/static/img/posts/thumb" +const POST_MEDIA_FULL_PATH = "web/static/media/posts/full" +const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb" const MAX_THREAD_COUNT = 50 +var SUPPORTED_VID_FORMATS = []string{".mp4", ".webm", ".ogg"} + func EnrichPost(body string) string { var b strings.Builder for _, rawLine := range strings.Split(body, "\n") { @@ -58,86 +62,142 @@ func EnrichPost(body string) string { return b.String() } -func SavePostFile(file *multipart.File, filename string) error { - dstPathFull := filepath.Join(POST_IMG_FULL_PATH, filename) - dstPathThumb := filepath.Join(POST_IMG_THUMB_PATH, filename) +func SavePostFile(file multipart.File, fileName string) (error, string, string) { + // read file type + buffer := make([]byte, 512) + file.Read(buffer) + file.Seek(0, 0) + + fileType := http.DetectContentType(buffer) + isFileVideo := strings.HasPrefix(fileType, "video/") + fileExt := strings.ToLower(filepath.Ext(fileName)) // FULL - if err := os.MkdirAll(POST_IMG_FULL_PATH, 0755); err != nil { + if err := os.MkdirAll(POST_MEDIA_FULL_PATH, 0755); err != nil { log.Printf("MkdirAll (full): %v", err) - return err + return err, "", "" } + dstPathFull := filepath.Join(POST_MEDIA_FULL_PATH, fileName) dstFull, err := os.Create(dstPathFull) if err != nil { log.Printf("os.Create (full): %v", err) - return err + return err, "", "" } defer dstFull.Close() - if _, err := io.Copy(dstFull, *file); err != nil { + if _, err := io.Copy(dstFull, file); err != nil { log.Printf("io.Copy (full): %v", err) - return err + return err, "", "" } - if _, err := (*file).Seek(0, 0); err != nil { // rewind file + if _, err := (file).Seek(0, 0); err != nil { // rewind file log.Printf("seek file (thumb): %v", err) - return err + return err, "", "" } // THUMBNAIL - if err := os.MkdirAll(POST_IMG_THUMB_PATH, 0755); err != nil { + dstPathThumb := filepath.Join(POST_MEDIA_THUMB_PATH, fileName) + if err := os.MkdirAll(POST_MEDIA_THUMB_PATH, 0755); err != nil { log.Printf("MkdirAll (thumb): %v", err) - return err + return err, "", "" } - img, _, err := image.Decode(*file) - if err != nil { - log.Printf("image.Decode: %v", err) - return err + var thumbFileName string + if isFileVideo { + fileNameNoExt := strings.TrimSuffix(fileName, fileExt) + thumbFileName = fileNameNoExt + ".jpg" + + inputPath := filepath.Join(POST_MEDIA_FULL_PATH, fileName) + outputPath := filepath.Join(POST_MEDIA_THUMB_PATH, thumbFileName) + + cmd := exec.Command( + "ffmpeg", + "-i", inputPath, + "-ss", "00:00:01.000", + "-vframes", "1", + "-vf", "scale=300:-1", + outputPath, + ) + + if err := cmd.Run(); err != nil { + log.Printf("ffmpeg error: %v", err) + return err, "", "" + } + + } else { + thumbFileName = fileName + + img, _, err := image.Decode(file) + if err != nil { + log.Printf("image.Decode: %v", err) + return err, "", "" + } + + var thumb image.Image + if img.Bounds().Dx() > 300 { + thumb = imaging.Resize(img, 300, 0, imaging.Lanczos) + } else { + thumb = img + } + + if err = imaging.Save(thumb, dstPathThumb); err != nil { + log.Printf("imaging.Save: %v", err) + return err, "", "" + } } - thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) - if err = imaging.Save(thumb, dstPathThumb); err != nil { - log.Printf("imaging.Save: %v", err) - return err - } - - return nil + return nil, fileName, thumbFileName } -type PostImageInfo struct { - Size int64 - Height int - Width int +type PostFileInfo struct { + Size int64 + Height int + Width int + IsVideo bool } -func GetPostImageInfo(mediaPath string) PostImageInfo { - var result PostImageInfo +func GetPostFileInfo(mediaPath string) PostFileInfo { + var result PostFileInfo - imagePath := path.Join(POST_IMG_FULL_PATH, mediaPath) + filePath := path.Join(POST_MEDIA_FULL_PATH, mediaPath) - fileInfo, err := os.Stat(imagePath) + fileInfo, err := os.Stat(filePath) if err != nil { - log.Printf("Failed to get image file info at %s: %v", mediaPath, err) - return PostImageInfo{} + log.Printf("Failed to get file info at %s: %v", mediaPath, err) + return PostFileInfo{} } - file, err := os.Open(imagePath) + file, err := os.Open(filePath) if err != nil { - log.Printf("Failed to open image at %s: %v", mediaPath, err) - return PostImageInfo{} + log.Printf("Failed to open file at %s: %v", mediaPath, err) + return PostFileInfo{} } defer file.Close() - cfg, _, err := image.DecodeConfig(file) - if err != nil { - log.Printf("Failed to decode image at %s: %v", mediaPath, err) - return PostImageInfo{} - } + // read file type + buffer := make([]byte, 512) + file.Read(buffer) + file.Seek(0, 0) + + fileType := http.DetectContentType(buffer) + isFileVideo := strings.HasPrefix(fileType, "video/") result.Size = fileInfo.Size() - result.Width = cfg.Width - result.Height = cfg.Height + + if !isFileVideo { + cfg, _, err := image.DecodeConfig(file) + if err != nil { + log.Printf("Failed to decode image at %s: %v", mediaPath, err) + return PostFileInfo{} + } + + result.Width = cfg.Width + result.Height = cfg.Height + result.IsVideo = false + + } else { + result.IsVideo = true + } return result } @@ -163,3 +223,11 @@ func FormatBytes(bytes int64) string { return fmt.Sprintf("%d B", bytes) } } + +func FormatFileInfo(fileInfo PostFileInfo) string { + humanSize := FormatBytes(fileInfo.Size) + if fileInfo.IsVideo { + return fmt.Sprintf("(%s)", humanSize) + } + return fmt.Sprintf("(%s, %dx%d)", humanSize, fileInfo.Width, fileInfo.Height) +} diff --git a/web/main.go b/web/main.go index 37069ba..6382bb2 100644 --- a/web/main.go +++ b/web/main.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "path/filepath" + "slices" "strconv" "strings" "time" @@ -155,13 +156,14 @@ func main() { defer file.Close() filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename) - if err := util.SavePostFile(&file, filename); err != nil { + err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename) + if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) log.Printf("savePostFile: %v", err) return } - if err := database.PutThread(db, slug, subject, body, filename, util.HashIp(ip)); err != nil { + if err := database.PutThread(db, slug, subject, body, savedMediaPath, savedThumbPath, util.HashIp(ip)); err != nil { http.Error(w, "Failed to create thread", http.StatusInternalServerError) log.Printf("PutThread: %v", err) return @@ -180,13 +182,6 @@ func main() { return } - threadIdStr := chi.URLParam(r, "threadId") - threadId, err := strconv.Atoi(threadIdStr) - if err != nil { - http.Error(w, "Invalid thread id", http.StatusBadRequest) - return - } - if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil { http.Error(w, "Failed to parse form", http.StatusBadRequest) log.Printf("ParseMultipartForm: %v", err) @@ -195,6 +190,7 @@ func main() { body := strings.TrimSpace(r.FormValue("body")) mediaPath := "" + thumbPath := "" if body == "" { http.Error(w, "Malformed post body", http.StatusBadRequest) @@ -204,22 +200,48 @@ func main() { file, header, err := r.FormFile("file") if err != nil { if !errors.Is(err, http.ErrMissingFile) { - http.Error(w, "Failed to retrive file from form", http.StatusBadRequest) + http.Error(w, "Failed to retrieve file from form", http.StatusBadRequest) log.Printf("FormFile: %v", err) return } } else { defer file.Close() - filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename) - if err := util.SavePostFile(&file, filename); err != nil { + + isFileVideo := false + fileExt := strings.ToLower(filepath.Ext(header.Filename)) + + // file type + buffer := make([]byte, 512) + file.Read(buffer) + file.Seek(0, 0) + fileType := http.DetectContentType(buffer) + + isFileVideo = strings.HasPrefix(fileType, "video/") + + if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) { + http.Error(w, "Unsupported file format", http.StatusBadRequest) + return + } + + filename := strconv.FormatInt(time.Now().UnixNano(), 10) + fileExt + err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename) + if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) log.Printf("savePostFile: %v", err) return } - mediaPath = filename + mediaPath = savedMediaPath + thumbPath = savedThumbPath } - if err := database.PutPost(db, slug, threadId, body, mediaPath, util.HashIp(ip)); err != nil { + threadIdStr := chi.URLParam(r, "threadId") + threadId, err := strconv.Atoi(threadIdStr) + if err != nil { + http.Error(w, "Invalid thread id", http.StatusBadRequest) + return + } + + if err := database.PutPost(db, slug, threadId, body, mediaPath, thumbPath, util.HashIp(ip)); err != nil { http.Error(w, "Failed to create post", http.StatusInternalServerError) log.Printf("PutPost: %v", err) return @@ -262,7 +284,7 @@ func main() { Subject: thread.Subject, Body: op.Body, ThreadURL: fmt.Sprintf("/%s/threads/%d", slug, thread.Id), - MediaPath: op.MediaPath, + ThumbPath: op.ThumbPath, ReplyCount: len(posts), IpCount: SumUniquePostIps(posts), }) diff --git a/web/static/index.css b/web/static/index.css index 2cc6b40..b0ea980 100644 --- a/web/static/index.css +++ b/web/static/index.css @@ -220,6 +220,14 @@ body { max-width: 100%; } +.post-vid { + display: block; + float: left; + margin: 4px 12px 4px 0; + height: auto; + max-width: 100%; +} + .post-img-info { display: inline-block; font-size: 10px; diff --git a/web/static/img/banners/comfy.png b/web/static/media/banners/comfy.png similarity index 100% rename from web/static/img/banners/comfy.png rename to web/static/media/banners/comfy.png diff --git a/web/static/img/banners/g.png b/web/static/media/banners/g.png similarity index 100% rename from web/static/img/banners/g.png rename to web/static/media/banners/g.png diff --git a/web/static/img/clavis.png b/web/static/media/clavis.png similarity index 100% rename from web/static/img/clavis.png rename to web/static/media/clavis.png diff --git a/web/static/thread.js b/web/static/thread.js index 0f5d91b..ae8c4c7 100644 --- a/web/static/thread.js +++ b/web/static/thread.js @@ -36,15 +36,32 @@ function insertHeaderReplies() { } } -function togglePostImage(img) { - const filename = img.src.split("/").at(-1); - if (img.classList.contains("post-img-full")) { - img.classList.remove("post-img-full"); - img.src = "/static/img/posts/thumb/" + filename; +function togglePostFile(imgEl) { + const isVideo = imgEl.dataset.full.endsWith(".mp4") || imgEl.dataset.full.endsWith(".webm") || imgEl.dataset.full.endsWith(".ogg"); + + + if (isVideo) { + const vidEl = imgEl.parentElement.querySelector("video"); + if (vidEl.style.display === "none") { + vidEl.src = "/static/media/posts/full/" + imgEl.dataset.full; + vidEl.style.display = ""; + imgEl.style.display = "none"; + } + else { + vidEl.src = ""; + vidEl.style.display = "none"; + imgEl.style.display = ""; + } } else { - img.classList.add("post-img-full"); - img.src = "/static/img/posts/full/" + filename; + if (imgEl.classList.contains("post-img-full")) { + imgEl.classList.remove("post-img-full"); + imgEl.src = "/static/media/posts/thumb/" + imgEl.dataset.thumb; + } + else { + imgEl.classList.add("post-img-full"); + imgEl.src = "/static/media/posts/full/" + imgEl.dataset.full; + } } } diff --git a/web/views/board.templ b/web/views/board.templ index 2dd2c0b..bb1d9ad 100644 --- a/web/views/board.templ +++ b/web/views/board.templ @@ -42,7 +42,15 @@ templ NewThreadForm(board database.Board) {
+
@@ -128,7 +130,14 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post