Add pinned and locked to thread table and setup routes

This commit is contained in:
Dominic Ferrando
2025-04-24 18:07:13 -04:00
parent 1fb4f67bb6
commit 2642e28c7b
5 changed files with 224 additions and 127 deletions
+118 -114
View File
@@ -58,7 +58,7 @@ func GetBoard(db *sql.DB, slug string) (Board, error) {
func GetThreads(db *sql.DB, boardSlug string) ([]Thread, error) { func GetThreads(db *sql.DB, boardSlug string) ([]Thread, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, board_slug, subject, created_at, bumped_at SELECT id, board_slug, subject, created_at, bumped_at, pinned, locked
FROM threads FROM threads
WHERE board_slug = ?`, boardSlug) WHERE board_slug = ?`, boardSlug)
@@ -70,7 +70,9 @@ func GetThreads(db *sql.DB, boardSlug string) ([]Thread, error) {
var result []Thread var result []Thread
for rows.Next() { for rows.Next() {
var t Thread var t Thread
err := rows.Scan(&t.Id, &t.BoardSlug, &t.Subject, &t.CreatedAt, &t.BumpedAt) err := rows.Scan(
&t.Id, &t.BoardSlug, &t.Subject, &t.CreatedAt, &t.BumpedAt,
&t.Pinned, &t.Locked)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -82,79 +84,19 @@ func GetThreads(db *sql.DB, boardSlug string) ([]Thread, error) {
func GetThread(db *sql.DB, threadId int) (Thread, error) { func GetThread(db *sql.DB, threadId int) (Thread, error) {
row := db.QueryRow(` row := db.QueryRow(`
SELECT id, board_slug, subject, created_at, bumped_at SELECT id, board_slug, subject, created_at, bumped_at, pinned, locked
FROM threads FROM threads
WHERE id = ?`, threadId) WHERE id = ?`, threadId)
var thread Thread var t Thread
err := row.Scan(&thread.Id, &thread.BoardSlug, &thread.Subject, &thread.CreatedAt, &thread.BumpedAt) err := row.Scan(
&t.Id, &t.BoardSlug, &t.Subject, &t.CreatedAt, &t.BumpedAt,
&t.Pinned, &t.Locked)
if err != nil { if err != nil {
return Thread{}, err return Thread{}, err
} }
return thread, row.Err() return t, row.Err()
}
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, thumb_path, banned
FROM posts
WHERE thread_id = ?`, threadId)
if err != nil {
return nil, err
}
defer rows.Close()
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, &p.ThumbPath, &p.Banned)
if err != nil {
return nil, err
}
result = append(result, p)
}
return result, rows.Err()
}
func GetPost(db *sql.DB, postId int) (Post, error) {
row := db.QueryRow(`
SELECT id, thread_id, author, body, created_at, media_path,
ip_hash, number, thumb_path, banned
FROM posts
WHERE id = ?`, postId)
var r Post
err := row.Scan(
&r.Id, &r.ThreadId, &r.Author, &r.Body, &r.CreatedAt, &r.MediaPath,
&r.IpHash, &r.Number, &r.ThumbPath, &r.Banned)
if err != nil {
return Post{}, err
}
return r, row.Err()
}
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, thumb_path, banned
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, &r.ThumbPath, &r.Banned)
if err != nil {
return Post{}, err
}
return r, row.Err()
} }
func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaPath string, thumbPath string, ip_hash string) (int, error) { func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaPath string, thumbPath string, ip_hash string) (int, error) {
@@ -215,38 +157,6 @@ func PutThread(db *sql.DB, boardSlug string, subject string, body string, mediaP
return int(threadIdStr), nil return int(threadIdStr), nil
} }
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
INNER JOIN threads t ON p.thread_id = t.id
WHERE t.board_slug = ?`, boardSlug)
var latestPostNumber sql.NullInt64
if err := row.Scan(&latestPostNumber); err != nil {
return err
}
newPostNumber := 1
if latestPostNumber.Valid {
newPostNumber = int(latestPostNumber.Int64) + 1
}
_, err := db.Exec(`
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
}
_, err = db.Exec(`UPDATE threads SET bumped_at = CURRENT_TIMESTAMP where id = ?`, threadId)
if err != nil {
return err
}
return nil
}
func DeleteThread(db Queryer, threadId int) error { func DeleteThread(db Queryer, threadId int) error {
// cleanup images // cleanup images
rows, err := db.Query(` rows, err := db.Query(`
@@ -294,6 +204,100 @@ func DeleteThread(db Queryer, threadId int) error {
return nil return nil
} }
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, thumb_path, banned
FROM posts
WHERE thread_id = ?`, threadId)
if err != nil {
return nil, err
}
defer rows.Close()
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, &p.ThumbPath, &p.Banned)
if err != nil {
return nil, err
}
result = append(result, p)
}
return result, rows.Err()
}
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, thumb_path, banned
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, &r.ThumbPath, &r.Banned)
if err != nil {
return Post{}, err
}
return r, row.Err()
}
func GetPost(db *sql.DB, postId int) (Post, error) {
row := db.QueryRow(`
SELECT id, thread_id, author, body, created_at, media_path,
ip_hash, number, thumb_path, banned
FROM posts
WHERE id = ?`, postId)
var r Post
err := row.Scan(
&r.Id, &r.ThreadId, &r.Author, &r.Body, &r.CreatedAt, &r.MediaPath,
&r.IpHash, &r.Number, &r.ThumbPath, &r.Banned)
if err != nil {
return Post{}, err
}
return r, row.Err()
}
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
INNER JOIN threads t ON p.thread_id = t.id
WHERE t.board_slug = ?`, boardSlug)
var latestPostNumber sql.NullInt64
if err := row.Scan(&latestPostNumber); err != nil {
return err
}
newPostNumber := 1
if latestPostNumber.Valid {
newPostNumber = int(latestPostNumber.Int64) + 1
}
_, err := db.Exec(`
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
}
_, err = db.Exec(`UPDATE threads SET bumped_at = CURRENT_TIMESTAMP where id = ?`, threadId)
if err != nil {
return err
}
return nil
}
func DeletePost(db *sql.DB, postId int) error { func DeletePost(db *sql.DB, postId int) error {
// cleanup images // cleanup images
row := db.QueryRow(` row := db.QueryRow(`
@@ -336,20 +340,6 @@ func DeletePost(db *sql.DB, postId int) error {
return nil return nil
} }
func GetAdmin(db *sql.DB, username string) (Admin, error) {
row := db.QueryRow(`
SELECT username, password
FROM admins
WHERE username = ?`, username)
var result Admin
if err := row.Scan(&result.Username, &result.Password); err != nil {
return Admin{}, nil
}
return result, nil
}
func BanIp(db *sql.DB, ipHash string, reason string, expiration time.Time) error { func BanIp(db *sql.DB, ipHash string, reason string, expiration time.Time) error {
log.Printf("IP: %s, reason: %s, expiration: %v", ipHash, reason, expiration) log.Printf("IP: %s, reason: %s, expiration: %v", ipHash, reason, expiration)
// if the ban already exists for ip, only update if its greater than existing // if the ban already exists for ip, only update if its greater than existing
@@ -393,3 +383,17 @@ func GetBan(db *sql.DB, ip string) (Ban, error) {
return r, nil return r, nil
} }
func GetAdmin(db *sql.DB, username string) (Admin, error) {
row := db.QueryRow(`
SELECT username, password
FROM admins
WHERE username = ?`, username)
var result Admin
if err := row.Scan(&result.Username, &result.Password); err != nil {
return Admin{}, nil
}
return result, nil
}
+2
View File
@@ -15,6 +15,8 @@ type Thread struct {
Subject string Subject string
CreatedAt time.Time CreatedAt time.Time
BumpedAt time.Time BumpedAt time.Time
Pinned bool
Locked bool
} }
type Post struct { type Post struct {
+2
View File
@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS threads (
subject TEXT NOT NULL DEFAULT '', subject TEXT NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
bumped_at DATETIME DEFAULT CURRENT_TIMESTAMP, bumped_at DATETIME DEFAULT CURRENT_TIMESTAMP,
pinned BOOLEAN NOT NULL DEFAULT 0,
locked BOOLEAN NOT NULL DEFAULT 0,
FOREIGN KEY (board_slug) REFERENCES boards(slug) ON DELETE CASCADE ON UPDATE CASCADE FOREIGN KEY (board_slug) REFERENCES boards(slug) ON DELETE CASCADE ON UPDATE CASCADE
); );
+60 -12
View File
@@ -428,6 +428,8 @@ func main() {
ThumbPath: op.ThumbPath, ThumbPath: op.ThumbPath,
ReplyCount: len(posts), ReplyCount: len(posts),
IpCount: len(uniqueIpHashes), IpCount: len(uniqueIpHashes),
Pinned: thread.Pinned,
Locked: thread.Locked,
}) })
} }
@@ -526,19 +528,50 @@ func main() {
r.Route("/admin", func(r chi.Router) { r.Route("/admin", func(r chi.Router) {
r.Use(AdminOnlyMiddleware) r.Use(AdminOnlyMiddleware)
r.Post("/logout", func(w http.ResponseWriter, r *http.Request) { r.Patch("/threads/{threadId}/lock", func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("comfy_admin"); err == nil { threadIdStr := chi.URLParam(r, "threadId")
util.DeleteAdminSession(c.Value) threadId, err := strconv.Atoi(threadIdStr)
if err != nil {
http.Error(w, "Invalid thread id", http.StatusBadRequest)
return
}
lockedStr := r.URL.Query().Get("locked")
locked, err := strconv.ParseBool(lockedStr)
if err != nil {
http.Error(w, "Invalid values for 'locked'", http.StatusBadRequest)
return
}
_, err = db.Exec(`UPDATE threads SET locked = ? WHERE id = ?`, locked, threadId)
if err != nil {
log.Println("Updating thread 'locked': ", err)
http.Error(w, "Failed to lock thread: "+threadIdStr, http.StatusInternalServerError)
return
}
})
r.Patch("/threads/{threadId}/pin", func(w http.ResponseWriter, r *http.Request) {
threadIdStr := chi.URLParam(r, "threadId")
threadId, err := strconv.Atoi(threadIdStr)
if err != nil {
http.Error(w, "Invalid thread id", http.StatusBadRequest)
return
}
pinnedStr := r.URL.Query().Get("pinned")
pinned, err := strconv.ParseBool(pinnedStr)
if err != nil {
http.Error(w, "Invalid values for 'pinned'", http.StatusBadRequest)
return
}
_, err = db.Exec(`UPDATE threads SET pinned = ? WHERE id = ?`, pinned, threadId)
if err != nil {
log.Println("Updating thread 'pinned': ", err)
http.Error(w, "Failed to pin thread: "+threadIdStr, http.StatusInternalServerError)
return
} }
http.SetCookie(w, &http.Cookie{
Name: "comfy_admin",
Value: "",
HttpOnly: true,
Secure: !util.DevMode,
Expires: time.Now(),
SameSite: http.SameSiteStrictMode,
Path: "/",
})
}) })
r.Delete("/threads/{threadId}", func(w http.ResponseWriter, r *http.Request) { r.Delete("/threads/{threadId}", func(w http.ResponseWriter, r *http.Request) {
@@ -612,6 +645,21 @@ func main() {
return return
} }
}) })
r.Post("/logout", func(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie("comfy_admin"); err == nil {
util.DeleteAdminSession(c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "comfy_admin",
Value: "",
HttpOnly: true,
Secure: !util.DevMode,
Expires: time.Now(),
SameSite: http.SameSiteStrictMode,
Path: "/",
})
})
}) })
// ----------------- // -----------------
+42 -1
View File
@@ -37,7 +37,7 @@ templ Board(board database.Board, isAdmin bool) {
<option value="large">Large</option> <option value="large">Large</option>
</select> </select>
<input style="margin-left: 10px;" oninput="applyCatalogSearch()" id="catalogSearch" placeholder="Search"/> <input style="margin-left: 10px;" oninput="applyCatalogSearch()" id="catalogSearch" placeholder="Search"/>
<a style="margin-left: 10px;" _="on click trigger refreshPosts on body" class="link-button">[Refresh]</a> <a style _="on click trigger refreshPosts on body" class="link-button">[Refresh]</a>
</div> </div>
<hr/> <hr/>
<div <div
@@ -58,6 +58,8 @@ type CatalogThreadPreview struct {
ThumbPath string ThumbPath string
ReplyCount int ReplyCount int
IpCount int IpCount int
Pinned bool
Locked bool
} }
type CatalogContext struct { type CatalogContext struct {
@@ -95,6 +97,12 @@ templ ThreadsCatalog(previews []CatalogThreadPreview, catalogContext CatalogCont
</strong> </strong>
<h1> <h1>
<a href={ templ.URL(preview.ThreadURL) } class="catalog-preview-link">{ preview.Subject }</a> <a href={ templ.URL(preview.ThreadURL) } class="catalog-preview-link">{ preview.Subject }</a>
if preview.Locked {
<span>Locked</span>
}
if preview.Pinned {
<span>Pinned</span>
}
</h1> </h1>
<p> <p>
@templ.Raw(util.EnrichPost(preview.Body)) @templ.Raw(util.EnrichPost(preview.Body))
@@ -111,6 +119,39 @@ templ ThreadsCatalog(previews []CatalogThreadPreview, catalogContext CatalogCont
_="on htmx:afterRequest trigger refreshPosts on body" _="on htmx:afterRequest trigger refreshPosts on body"
hx-confirm="Are you sure you wish to delete this thread?" hx-confirm="Are you sure you wish to delete this thread?"
>Delete</button> >Delete</button>
<button
class="link-button"
hx-patch={ fmt.Sprintf("/admin/threads/%d/pin?pinned=%t", preview.ThreadId, !preview.Pinned) }
hx-swap="none"
_="on htmx:afterRequest trigger refreshPosts on body"
hx-confirm="Are you sure you wish to toggle pin this thread?"
>
if preview.Pinned {
Unpin
} else {
Pin
}
</button>
<button
class="link-button"
hx-patch={ fmt.Sprintf("/admin/threads/%d/lock?locked=%t", preview.ThreadId, !preview.Locked) }
hx-swap="none"
_="on htmx:afterRequest trigger refreshPosts on body"
hx-confirm="Are you sure you wish to toggle lock this thread?"
>
if preview.Locked {
Unlock
} else {
Lock
}
</button>
<button
class="link-button"
hx-delete={ fmt.Sprintf("/admin/threads/%d", preview.ThreadId) }
hx-swap="none"
_="on htmx:afterRequest trigger refreshPosts on body"
hx-confirm="Are you sure you wish to delete this thread?"
>Delete</button>
<button <button
class="admin-dialog-close-btn link-button" class="admin-dialog-close-btn link-button"
_={ fmt.Sprintf("on click call #%s-dialog.close()", elThreadId) } _={ fmt.Sprintf("on click call #%s-dialog.close()", elThreadId) }