1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-08-26 10:36:23 -07:00

Add spoilered column and migration, update tests in gctemplates to use goquery to not have to test against really big strings

This commit is contained in:
Eggbertx 2025-04-13 01:18:55 -07:00
parent 052a75da28
commit 3dc45fef53
12 changed files with 322 additions and 299 deletions

View file

@ -193,5 +193,17 @@ func updateMysqlDB(ctx context.Context, dbu *GCDatabaseUpdater, sqlConfig *confi
}
}
// add spoilered column to DBPREFIXthreads
dataType, err = common.ColumnType(ctx, db, nil, "spoilered", "DBPREFIXthreads", sqlConfig)
if err != nil {
return err
}
if dataType == "" {
query = `ALTER TABLE DBPREFIXthreads ADD COLUMN spoilered BOOL NOT NULL DEFAULT FALSE`
if _, err = db.ExecContextSQL(ctx, nil, query); err != nil {
return err
}
}
return nil
}

View file

@ -35,21 +35,31 @@ type migrationPost struct {
func (*Pre2021Migrator) migratePost(tx *sql.Tx, post *migrationPost, errEv *zerolog.Event) error {
var err error
opts := &gcsql.RequestOptions{Tx: tx}
thread := &gcsql.Thread{
ID: post.ThreadID,
BoardID: post.boardID,
Locked: post.locked,
Stickied: post.stickied,
Anchored: post.autosage,
Cyclic: false,
}
if post.oldParentID == 0 {
// migrating post was a thread OP, create the row in the threads table
if post.ThreadID, err = gcsql.CreateThread(opts, post.boardID, false, post.stickied, post.autosage, false); err != nil {
if err = gcsql.CreateThread(opts, thread); err != nil {
errEv.Err(err).Caller().
Int("boardID", post.boardID).
Msg("Failed to create thread")
}
post.ThreadID = thread.ID
}
// insert thread top post
if err = post.Insert(true, post.boardID, false, post.stickied, post.autosage, false, opts); err != nil {
if err = post.Insert(true, thread, true, opts); err != nil {
errEv.Err(err).Caller().
Int("boardID", post.boardID).
Int("threadID", post.ThreadID).
Msg("Failed to insert thread OP")
return err
}
if post.filename != "" {

View file

@ -79,11 +79,11 @@ func boardPagePathTmplFunc(board *gcsql.Board, page int) string {
return config.WebPath(board.Dir, strconv.Itoa(page)+".html")
}
func getBoardDefaultStyleTmplFunc(dir string) string {
func getBoardDefaultStyleTmplFunc(dir string) (string, error) {
boardCfg := config.GetBoardConfig(dir)
if !boardCfg.IsGlobal() {
// /<board>/board.json exists, overriding the default them and theme set in SQL
return boardCfg.DefaultStyle
return boardCfg.DefaultStyle, nil
}
var defaultStyle string
err := gcsql.QueryRowTimeoutSQL(nil, "SELECT default_style FROM DBPREFIXboards WHERE dir = ?",
@ -92,9 +92,9 @@ func getBoardDefaultStyleTmplFunc(dir string) string {
gcutil.LogError(err).Caller().
Str("board", dir).
Msg("Unable to get default style attribute of board")
return boardCfg.DefaultStyle
return boardCfg.DefaultStyle, err
}
return defaultStyle
return defaultStyle, nil
}
func sectionBoardsTmplFunc(sectionID int) []gcsql.Board {

View file

@ -353,6 +353,16 @@ func (p *Post) InCyclicThread() (bool, error) {
return cyclic, err
}
// InSpoileredThread returns true if the post is in a spoilered thread
func (p *Post) InSpoileredThread() (bool, error) {
var spoilered bool
err := QueryRowTimeoutSQL(nil, "SELECT spoilered FROM DBPREFIXthreads WHERE id = ?", []any{p.ThreadID}, []any{&spoilered})
if errors.Is(err, sql.ErrNoRows) {
return false, ErrThreadDoesNotExist
}
return spoilered, err
}
// Delete sets the post as deleted and sets the deleted_at timestamp to the current time
func (p *Post) Delete(requestOptions ...*RequestOptions) error {
shouldCommit := len(requestOptions) == 0
@ -391,8 +401,9 @@ func (p *Post) Delete(requestOptions ...*RequestOptions) error {
return nil
}
// Insert inserts the post into the database with the optional given options
func (p *Post) Insert(bumpThread bool, boardID int, locked bool, stickied bool, anchored bool, cyclical bool, requestOptions ...*RequestOptions) error {
// Insert inserts the post into the database with the optional given options. If force is not true and
// the thread is locked, it will return an error. Force should only be used for special cases (ex: migration)
func (p *Post) Insert(bumpThread bool, thread *Thread, force bool, requestOptions ...*RequestOptions) error {
opts := setupOptions(requestOptions...)
if len(requestOptions) == 0 {
opts.Context, opts.Cancel = context.WithTimeout(context.Background(), gcdb.defaultTimeout)
@ -421,19 +432,20 @@ func (p *Post) Insert(bumpThread bool, boardID int, locked bool, stickied bool,
// thread doesn't exist yet, this is a new post
p.IsTopPost = true
var threadID int
threadID, err = CreateThread(opts, boardID, locked, stickied, anchored, cyclical)
if err != nil {
if err = CreateThread(opts, thread); err != nil {
return err
}
p.ThreadID = threadID
} else {
var threadIsLocked bool
if err = QueryRow(opts, "SELECT locked FROM DBPREFIXthreads WHERE id = ?",
[]any{p.ThreadID}, []any{&threadIsLocked}); err != nil {
return err
}
if threadIsLocked {
return ErrThreadLocked
if !force {
var threadIsLocked bool
if err = QueryRow(opts, "SELECT locked FROM DBPREFIXthreads WHERE id = ?",
[]any{p.ThreadID}, []any{&threadIsLocked}); err != nil {
return err
}
if threadIsLocked {
return ErrThreadLocked
}
}
}

View file

@ -80,11 +80,11 @@ func createThreadTestRun(t *testing.T, driver string) {
mock.ExpectPrepare(query).ExpectQuery().
WithArgs(1).WillReturnRows(mock.NewRows([]string{"locked"}).AddRow(false))
threadID, err := CreateThread(nil, 1, false, false, false, false)
if !assert.NoError(t, err) {
thread := &Thread{BoardID: 1}
if !assert.NoError(t, CreateThread(nil, thread)) {
t.FailNow()
}
p := Post{ThreadID: threadID, Message: "test", MessageRaw: "test", IP: "192.168.56.1", IsTopPost: true, CreatedOn: time.Now()}
p := Post{ThreadID: thread.ID, Message: "test", MessageRaw: "test", IP: "192.168.56.1", IsTopPost: true, CreatedOn: time.Now()}
if driver == "mysql" {
query = insertIntoPostsMySQL
@ -103,9 +103,9 @@ func createThreadTestRun(t *testing.T, driver string) {
query = `UPDATE threads SET last_bump = CURRENT_TIMESTAMP WHERE id = \$1`
}
mock.ExpectPrepare(query).ExpectExec().
WithArgs(threadID).WillReturnResult(sqlmock.NewResult(1, 1))
WithArgs(thread.ID).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
if !assert.NoError(t, p.Insert(true, 1, false, false, false, false)) {
if !assert.NoError(t, p.Insert(true, thread, false)) {
t.FailNow()
}
assert.NoError(t, mock.ExpectationsWereMet())

View file

@ -280,6 +280,7 @@ type Thread struct {
Stickied bool // sql: stickied
Anchored bool // sql: anchored
Cyclic bool // sql: cyclical
Spoilered bool // sql: spoilered
LastBump time.Time // sql: last_bump
DeletedAt time.Time // sql: deleted_at
IsDeleted bool // sql: is_deleted

View file

@ -20,23 +20,20 @@ var (
)
// CreateThread creates a new thread in the database with the given board ID and statuses
func CreateThread(requestOptions *RequestOptions, boardID int, locked bool, stickied bool, anchored bool, cyclic bool) (threadID int, err error) {
func CreateThread(requestOptions *RequestOptions, thread *Thread) (err error) {
const lockedQuery = `SELECT locked FROM DBPREFIXboards WHERE id = ?`
const insertQuery = `INSERT INTO DBPREFIXthreads (board_id, locked, stickied, anchored, cyclical) VALUES (?,?,?,?,?)`
var boardIsLocked bool
if err = QueryRow(requestOptions, lockedQuery, []any{boardID}, []any{&boardIsLocked}); err != nil {
return 0, err
if err = QueryRow(requestOptions, lockedQuery, []any{&thread.BoardID}, []any{&boardIsLocked}); err != nil {
return err
}
if boardIsLocked {
return 0, ErrBoardIsLocked
return ErrBoardIsLocked
}
if _, err = Exec(requestOptions, insertQuery, boardID, locked, stickied, anchored, cyclic); err != nil {
return 0, err
if _, err = Exec(requestOptions, insertQuery, &thread.BoardID, &thread.Locked, &thread.Stickied, &thread.Anchored, &thread.Cyclic); err != nil {
return err
}
if err = QueryRow(requestOptions, "SELECT MAX(id) FROM DBPREFIXthreads", nil, []any{&threadID}); err != nil {
return 0, err
}
return threadID, nil
return QueryRow(requestOptions, "SELECT MAX(id) FROM DBPREFIXthreads", nil, []any{&thread.ID})
}
// GetThread returns a a thread object from the database, given its ID

View file

@ -2,43 +2,18 @@ package templatetests_test
import (
"bytes"
"io"
"testing"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/server/serverutil"
"github.com/stretchr/testify/assert"
)
const (
headBeginning = `<!DOCTYPE html><html lang="en"><head>` +
`<meta charset="UTF-8"><meta name="viewport"content="width=device-width, initial-scale=1.0">`
headEndAndBodyStart = `<link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/>` +
`<link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script>` +
`<script type="text/javascript"src="/js/gochan.js"defer></script></head>` +
`<body><div id="topbar"><div class="topbar-section"><a href="/"class="topbar-item">home</a></div></div>` +
`<header><h1 id="board-title">Gochan</h1></header>` +
`<div id="content"><div class="section-block banpage-block">`
normalBanHeader = headBeginning + `<title>YOU ARE BANNED:(</title>` + headEndAndBodyStart +
`<div class="section-title-block"><span class="section-title ban-title">YOU ARE BANNED:(</span></div>` +
`<div class="section-body"><div id="ban-info">`
bannedForeverHeader = headBeginning + `<title>YOU'RE PERMABANNED,&nbsp;IDIOT!</title>` + headEndAndBodyStart +
`<div class="section-title-block"><span class="section-title ban-title">YOU'RE PERMABANNED,IDIOT!</span></div>` +
`<div class="section-body"><div id="ban-info">`
appealForm = `<form id="appeal-form"action="/post"method="POST">` +
`<input type="hidden"name="board"value=""><input type="hidden"name="banid"value="0">` +
`<textarea rows="4"cols="48"name="appealmsg"id="postmsg"placeholder="Appeal message"></textarea>` +
`<input type="submit"name="doappeal"value="Submit"/><br/></form>`
footer = `<footer>Powered by<a href="http://github.com/gochan-org/gochan/">Gochan 4.0</a><br /></footer></div></body></html>`
)
var (
testingSiteConfig = &config.SiteConfig{
SiteName: "Gochan",
@ -72,18 +47,6 @@ var (
AnonymousName: "Anonymous Coward",
}
simpleBoard2 = &gcsql.Board{
ID: 2,
SectionID: 2,
URI: "sup",
Dir: "sup",
Title: "Gochan Support board",
Subtitle: "Board for helping out gochan users/admins",
Description: "Board for helping out gochan users/admins",
DefaultStyle: "yotsuba.css",
AnonymousName: "Anonymous Coward",
}
banPageCases = []templateTestCase{
{
desc: "appealable permaban",
@ -107,12 +70,21 @@ var (
DefaultStyle: "pipes.css",
},
},
expectedOutput: normalBanHeader +
`You are banned from posting on<span class="ban-boards">all boards</span>for the following reason:<p class="reason">ban message goes here</p>` +
`Your ban was placed on<time datetime="0001-01-01T00:00:00Z"class="ban-timestamp">Mon,January 01,0001 12:00:00 AM</time> and will <span class="ban-timestamp">not expire</span>.<br/>` +
`Your IP address is<span class="ban-ip">192.168.56.1</span>.<br /><br/>` +
`You may appeal this ban:<br/>` + appealForm + `</div></div></div>` +
footer,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "YOU ARE BANNED:(", doc.Find("title").Text())
assert.Equal(t, "all boards", doc.Find(".ban-boards").Text())
assert.Equal(t, "ban message goes here", doc.Find(".reason").Text())
banTime := doc.Find(".ban-timestamp").First()
assert.Equal(t, "0001-01-01T00:00:00Z", banTime.AttrOr("datetime", ""))
assert.Equal(t, "Mon,January 01,0001 12:00:00 AM", banTime.Text())
assert.Equal(t, "not expire", banTime.Next().Text())
assert.Equal(t, "192.168.56.1", doc.Find(".ban-ip").Text())
assert.Equal(t, "You may appeal this ban:", doc.Find("#appeal-form").Prev().Nodes[0].PrevSibling.Data)
},
},
{
desc: "unappealable permaban (banned forever)",
@ -136,14 +108,22 @@ var (
DefaultStyle: "pipes.css",
},
},
expectedOutput: bannedForeverHeader + `You are banned from posting on<span class="ban-boards">all boards</span>for the following reason:` +
`<p class="reason">ban message goes here</p>Your ban was placed on<time datetime="0001-01-01T00:00:00Z"class="ban-timestamp">Mon,January 01,0001 12:00:00 AM</time> ` +
`and will <span class="ban-timestamp">not expire</span>.<br/>` +
`Your IP address is<span class="ban-ip">192.168.56.1</span>.<br /><br/>You may<span class="ban-timestamp">not</span> appeal this ban.<br /></div>` +
`<img id="banpage-image" src="/static/permabanned.jpg"/><br/>` +
`<audio id="jack"preload="auto"autobuffer loop><source src="/static/hittheroad.ogg"/><source src="/static/hittheroad.wav"/><source src="/static/hittheroad.mp3"/></audio>` +
`<script type="text/javascript">document.getElementById("jack").play();</script></div></div>` +
footer,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "YOU'RE PERMABANNED,\u00A0IDIOT!", doc.Find("title").Text())
assert.Equal(t, "all boards", doc.Find(".ban-boards").Text())
assert.Equal(t, "ban message goes here", doc.Find(".reason").Text())
banTime := doc.Find(".ban-timestamp").First()
assert.Equal(t, "0001-01-01T00:00:00Z", banTime.AttrOr("datetime", ""))
assert.Equal(t, "Mon,January 01,0001 12:00:00 AM", banTime.Text())
assert.Equal(t, "not expire", banTime.Next().Text())
assert.Equal(t, "192.168.56.1", doc.Find(".ban-ip").Text())
assert.Equal(t, "/static/permabanned.jpg", doc.Find("img#banpage-image").AttrOr("src", ""))
assert.Equal(t, 1, doc.Find("audio#jack").Length())
},
},
{
desc: "appealable temporary ban",
@ -166,8 +146,21 @@ var (
DefaultStyle: "pipes.css",
},
},
expectedOutput: normalBanHeader +
`You are banned from posting on<span class="ban-boards">all boards</span>for the following reason:<p class="reason">ban message goes here</p>Your ban was placed on<time datetime="0001-01-01T00:00:00Z"class="ban-timestamp">Mon,January 01,0001 12:00:00 AM</time> and will expire on <time class="ban-timestamp" datetime="0001-01-01T00:00:00Z">Mon, January 01, 0001 12:00:00 AM</time>.<br/>Your IP address is<span class="ban-ip">192.168.56.1</span>.<br /><br/>You may appeal this ban:<br/><form id="appeal-form"action="/post"method="POST"><input type="hidden"name="board"value=""><input type="hidden"name="banid"value="0"><textarea rows="4"cols="48"name="appealmsg"id="postmsg"placeholder="Appeal message"></textarea><input type="submit"name="doappeal"value="Submit"/><br/></form></div></div></div><footer>Powered by<a href="http://github.com/gochan-org/gochan/">Gochan 4.0</a><br /></footer></div></body></html>`,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "YOU ARE BANNED:(", doc.Find("title").Text())
assert.Equal(t, "all boards", doc.Find(".ban-boards").Text())
assert.Equal(t, "ban message goes here", doc.Find(".reason").Text())
banTime := doc.Find(".ban-timestamp").First()
assert.Equal(t, "0001-01-01T00:00:00Z", banTime.AttrOr("datetime", ""))
assert.Equal(t, "Mon,January 01,0001 12:00:00 AM", banTime.Text())
assert.Equal(t, "Mon, January 01, 0001 12:00:00 AM", banTime.Next().Text())
assert.Equal(t, "192.168.56.1", doc.Find(".ban-ip").Text())
assert.Equal(t, "You may appeal this ban:", doc.Find("#appeal-form").Prev().Nodes[0].PrevSibling.Data)
},
},
{
desc: "unappealable temporary ban",
@ -189,12 +182,14 @@ var (
DefaultStyle: "pipes.css",
},
},
expectedOutput: normalBanHeader + `You are banned from posting on<span class="ban-boards">all boards</span>for the following reason:` +
`<p class="reason">ban message goes here</p>` +
`Your ban was placed on<time datetime="0001-01-01T00:00:00Z"class="ban-timestamp">Mon,January 01,0001 12:00:00 AM</time> ` +
`and will expire on <time class="ban-timestamp" datetime="0001-01-01T00:00:00Z">Mon, January 01, 0001 12:00:00 AM</time>.<br/>` +
`Your IP address is<span class="ban-ip">192.168.56.1</span>.<br /><br/>You may<span class="ban-timestamp">not</span> appeal this ban.<br />` +
`</div></div></div>` + footer,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "YOU ARE BANNED:(", doc.Find("title").Text())
assert.Equal(t, "You are banned from posting onall boardsfor the following reason:ban message goes hereYour ban was placed onMon,January 01,0001 12:00:00 AM and will expire on Mon, January 01, 0001 12:00:00 AM.Your IP address is192.168.56.1.You maynot appeal this ban.", doc.Find("#ban-info").Text())
},
},
}
@ -209,23 +204,105 @@ var (
{ID: 1},
},
},
expectedOutput: boardPageHeaderBase +
`<form action="/util"method="POST"id="main-form"><div id="right-bottom-content"><div id="report-delbox"><input type="hidden"name="board"value="test"/><input type="hidden"name="boardid"value="1"/><label>[<input type="checkbox"name="fileonly"/>File only]</label> <input type="password" size="10" name="password" id="delete-password" /><input type="submit"name="delete_btn"value="Delete"onclick="return confirm('Are you sure you want to delete these posts?')"/><br/>Report reason:<input type="text"size="10"name="reason"id="reason"/><input type="submit"name="report_btn"value="Report"/><br/><input type="submit"name="edit_btn"value="Edit post"/>&nbsp;<input type="submit"name="move_btn"value="Move thread"/></div></div></form><div id="left-bottom-content"><a href="#"onClick="window.location.reload(); return false;">Update</a>|<a href="#">Scroll to top</a><br/><table id="pages"><tr><td>[<a href="/test/1.html">1</a>]</td></tr></table><span id="boardmenu-bottom">[<a href="/">home</a>]&nbsp;[]</span></div>` +
footer,
getDefaultStyle: true,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "/test/-Testing board", doc.Find("title").Text())
assert.Equal(t, "/test/-Testing board", doc.Find("#board-title").Text())
assert.Equal(t, "Board for testingCatalog | Bottom", doc.Find("#board-subtitle").Text())
assert.Equal(t, 1, doc.Find("#postbox-area").Length())
assert.Equal(t, 1, doc.Find("#main-form").Length())
assert.Equal(t, 0, doc.Find("#main-form .thread").Length())
},
},
{
desc: "base case, multi threads and pages",
data: map[string]any{
"boardConfig": simpleBoardConfig,
"board": simpleBoard1,
"numPages": 1,
"numPages": 2,
"sections": []gcsql.Section{
{ID: 1},
},
"threads": []map[string]any{
{
"Posts": []*building.Post{
{
ParentID: 1,
Post: gcsql.Post{
ID: 1,
IsTopPost: true,
Name: "Test name",
Tripcode: "Tripcode",
Subject: "Test subject",
Message: "Test message",
CreatedOn: time.Now(),
},
},
{
ParentID: 1,
Post: gcsql.Post{
ID: 2,
Name: "Test name 2",
Tripcode: "Tripcode",
Message: "Test message 2",
CreatedOn: time.Now(),
},
},
},
"OmittedPosts": 0,
},
{
"Posts": []*building.Post{
{
ParentID: 2,
Post: gcsql.Post{
ID: 3,
IsTopPost: true,
IsSecureTripcode: true,
Name: "Test name 3",
Tripcode: "Secure",
Subject: "Test subject 3",
Message: "Test message 3",
CreatedOn: time.Now(),
},
},
},
"OmittedPosts": 0,
},
},
},
getDefaultStyle: true,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "/test/-Testing board", doc.Find("title").Text())
assert.Equal(t, "/test/-Testing board", doc.Find("#board-title").Text())
assert.Equal(t, "Board for testingCatalog | Bottom", doc.Find("#board-subtitle").Text())
assert.Equal(t, 1, doc.Find("#postbox-area").Length())
assert.Equal(t, 1, doc.Find("#main-form").Length())
threads := doc.Find("#main-form .thread")
thread1 := doc.Find("#main-form .thread").Eq(0)
assert.Equal(t, 2, threads.Length())
assert.Equal(t, 1, thread1.Find(".op-post").Length())
assert.Equal(t, 1, thread1.Find(".reply-container").Length())
assert.Equal(t, "Test name", thread1.Find(".op-post .postername").Text())
assert.Equal(t, "!Tripcode", thread1.Find(".op-post .tripcode").Text())
thread2 := doc.Find("#main-form .thread").Eq(1)
assert.Equal(t, 1, thread2.Find(".op-post").Length())
assert.Equal(t, 0, thread2.Find(".reply-container").Length())
assert.Equal(t, "Test name 3", thread2.Find(".op-post .postername").Text())
assert.Equal(t, "!!Secure", thread2.Find(".op-post .tripcode").Text())
assert.Equal(t, 2, doc.Find("#left-bottom-content #pages a").Length())
},
expectedOutput: boardPageHeaderBase +
`<form action="/util"method="POST"id="main-form"><div id="right-bottom-content"><div id="report-delbox"><input type="hidden"name="board"value="test"/><input type="hidden"name="boardid"value="1"/><label>[<input type="checkbox"name="fileonly"/>File only]</label> <input type="password" size="10" name="password" id="delete-password" /><input type="submit"name="delete_btn"value="Delete"onclick="return confirm('Are you sure you want to delete these posts?')"/><br/>Report reason:<input type="text"size="10"name="reason"id="reason"/><input type="submit"name="report_btn"value="Report"/><br/><input type="submit"name="edit_btn"value="Edit post"/>&nbsp;<input type="submit"name="move_btn"value="Move thread"/></div></div></form><div id="left-bottom-content"><a href="#"onClick="window.location.reload(); return false;">Update</a>|<a href="#">Scroll to top</a><br/><table id="pages"><tr><td>[<a href="/test/1.html">1</a>]</td></tr></table><span id="boardmenu-bottom">[<a href="/">home</a>]&nbsp;[]</span></div>` +
footer,
},
}
@ -244,7 +321,14 @@ var (
".ext": "thumb.png",
},
},
expectedOutput: `const styles=[{Name:"Pipes",Filename:"pipes.css"},{Name:"Yotsuba A",Filename:"yotsuba.css"}];const defaultStyle="pipes.css";const webroot="/";const serverTZ=-1;const fileTypes=[".ext",];`,
validationFunc: func(t *testing.T, reader io.Reader) {
ba, err := io.ReadAll(reader)
if assert.NoError(t, err) {
assert.Equal(t,
`const styles=[{Name:"Pipes",Filename:"pipes.css"},{Name:"Yotsuba A",Filename:"yotsuba.css"}];const defaultStyle="pipes.css";const webroot="/";const serverTZ=-1;const fileTypes=[".ext",];`,
string(ba))
}
},
},
{
desc: "empty values",
@ -253,7 +337,14 @@ var (
"webroot": "",
"timezone": 0,
},
expectedOutput: `const styles=[];const defaultStyle="";const webroot="";const serverTZ=0;const fileTypes=[];`,
validationFunc: func(t *testing.T, reader io.Reader) {
ba, err := io.ReadAll(reader)
if assert.NoError(t, err) {
assert.Equal(t,
`const styles=[];const defaultStyle="";const webroot="";const serverTZ=0;const fileTypes=[];`,
string(ba))
}
},
},
{
desc: "escaped string",
@ -262,7 +353,14 @@ var (
"webroot": "",
"timezone": 0,
},
expectedOutput: `const styles=[];const defaultStyle="\&#34;a\\a\&#34;";const webroot="";const serverTZ=0;const fileTypes=[];`,
validationFunc: func(t *testing.T, reader io.Reader) {
ba, err := io.ReadAll(reader)
if assert.NoError(t, err) {
assert.Equal(t,
`const styles=[];const defaultStyle="\&#34;a\\a\&#34;";const webroot="";const serverTZ=0;const fileTypes=[];`,
string(ba))
}
},
},
}
@ -277,25 +375,18 @@ var (
{ID: 1},
},
},
expectedOutput: footer,
},
{
desc: "base footer test",
data: map[string]any{
"boardConfig": simpleBoardConfig,
"board": simpleBoard2,
"numPages": 3,
"sections": []gcsql.Section{
{ID: 1},
},
validationFunc: func(t *testing.T, reader io.Reader) {
ba, err := io.ReadAll(reader)
if assert.NoError(t, err) {
assert.Equal(t, `<footer>Powered by<a href="http://github.com/gochan-org/gochan/">Gochan 4.0</a><br /></footer></div></body></html>`, string(ba))
}
},
expectedOutput: footer,
},
}
baseHeaderCases = []templateTestCase{
{
desc: "Header Test /test/",
desc: "Header Test test board",
data: map[string]any{
"boardConfig": simpleBoardConfig,
"board": simpleBoard1,
@ -304,41 +395,22 @@ var (
{ID: 1},
},
},
expectedOutput: headBeginning +
`<title>/test/-Testing board</title>` +
`<link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/>` +
`<link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script>` +
`<script type="text/javascript"src="/js/gochan.js"defer></script>` +
`</head><body><div id="topbar"><div class="topbar-section">` +
`<a href="/"class="topbar-item">home</a></div>` +
`<div class="topbar-section"><a href="/test/"class="topbar-item"title="Testing board">/test/</a>` +
`<a href="/test2/" class="topbar-item" title="Testing board#2">/test2/</a></div></div>` +
`<div id="content">`,
},
{
desc: "Header Test /sup/",
data: map[string]any{
"boardConfig": simpleBoardConfig,
"board": simpleBoard2,
"numPages": 1,
"sections": []gcsql.Section{
{ID: 1},
},
getDefaultStyle: true,
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "/test/-Testing board", doc.Find("title").Text())
assert.Equal(t, "/css/global.css", doc.Find("link[rel='stylesheet']").AttrOr("href", ""))
assert.Equal(t, "/css/pipes.css", doc.Find("link#theme").AttrOr("href", ""))
assert.Equal(t, "/favicon.png", doc.Find("link[rel='shortcut icon']").AttrOr("href", ""))
assert.Equal(t, "/js/consts.js", doc.Find("script[src='/js/consts.js']").AttrOr("src", ""))
assert.Equal(t, "/js/gochan.js", doc.Find("script[src='/js/gochan.js']").AttrOr("src", ""))
assert.Equal(t, "home", doc.Find("#topbar a.topbar-item").First().Text())
assert.Equal(t, "/test/", doc.Find("#topbar a.topbar-item").Eq(1).Text())
assert.Equal(t, "/test2/", doc.Find("#topbar a.topbar-item").Eq(2).Text())
},
expectedOutput: headBeginning +
`<title>/sup/-Gochan Support board</title>` +
`<link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/>` +
`<link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script>` +
`<script type="text/javascript"src="/js/gochan.js"defer></script>` +
`</head><body><div id="topbar"><div class="topbar-section">` +
`<a href="/"class="topbar-item">home</a></div>` +
`<div class="topbar-section"><a href="/test/"class="topbar-item"title="Testing board">/test/</a>` +
`<a href="/test2/" class="topbar-item" title="Testing board#2">/test2/</a></div></div>` +
`<div id="content">`,
},
{
desc: "Perma Ban Header Test",
@ -362,141 +434,43 @@ var (
DefaultStyle: "pipes.css",
},
},
expectedOutput: `<!DOCTYPE html><html lang="en"><head>` +
`<meta charset="UTF-8"><meta name="viewport"content="width=device-width, initial-scale=1.0">` +
`<title>YOU'RE PERMABANNED,&nbsp;IDIOT!</title><link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/><link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script><script type="text/javascript"src="/js/gochan.js"defer></script>` +
`</head><body><div id="topbar"><div class="topbar-section"><a href="/"class="topbar-item">home</a></div></div><div id="content">`,
},
{
desc: "Appealable Perma Ban Header Test",
data: map[string]any{
"ban": &gcsql.IPBan{
RangeStart: "192.168.56.0",
RangeEnd: "192.168.56.255",
IPBanBase: gcsql.IPBanBase{
Permanent: true,
CanAppeal: true,
StaffID: 1,
Message: "ban message goes here",
},
},
"ip": "192.168.56.1",
"siteConfig": testingSiteConfig,
"systemCritical": config.SystemCriticalConfig{
WebRoot: "/",
},
"boardConfig": config.BoardConfig{
DefaultStyle: "pipes.css",
},
validationFunc: func(t *testing.T, reader io.Reader) {
doc, err := goquery.NewDocumentFromReader(reader)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.Equal(t, "YOU'RE PERMABANNED,\u00a0IDIOT!", doc.Find("title").Text())
assert.Equal(t, "/css/global.css", doc.Find("link[rel='stylesheet']").AttrOr("href", ""))
assert.Equal(t, "/css/pipes.css", doc.Find("link#theme").AttrOr("href", ""))
assert.Equal(t, "/favicon.png", doc.Find("link[rel='shortcut icon']").AttrOr("href", ""))
assert.Equal(t, "/js/consts.js", doc.Find("script[src='/js/consts.js']").AttrOr("src", ""))
assert.Equal(t, "/js/gochan.js", doc.Find("script[src='/js/gochan.js']").AttrOr("src", ""))
},
expectedOutput: `<!DOCTYPE html><html lang="en"><head>` +
`<meta charset="UTF-8"><meta name="viewport"content="width=device-width, initial-scale=1.0">` +
`<title>YOU ARE BANNED:(</title><link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/><link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script><script type="text/javascript"src="/js/gochan.js"defer></script>` +
`</head><body><div id="topbar"><div class="topbar-section"><a href="/"class="topbar-item">home</a></div></div><div id="content">`,
},
{
desc: "Appealable Temp Ban Header Test",
data: map[string]any{
"ban": &gcsql.IPBan{
RangeStart: "192.168.56.0",
RangeEnd: "192.168.56.255",
IPBanBase: gcsql.IPBanBase{
CanAppeal: true,
StaffID: 1,
Message: "ban message goes here",
},
},
"ip": "192.168.56.1",
"siteConfig": testingSiteConfig,
"systemCritical": config.SystemCriticalConfig{
WebRoot: "/",
},
"boardConfig": config.BoardConfig{
DefaultStyle: "pipes.css",
},
},
expectedOutput: `<!DOCTYPE html><html lang="en">` +
`<head><meta charset="UTF-8"><meta name="viewport"content="width=device-width, initial-scale=1.0">` +
`<title>YOU ARE BANNED:(</title><link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/><link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script><script type="text/javascript"src="/js/gochan.js"defer></script>` +
`</head><body><div id="topbar"><div class="topbar-section"><a href="/"class="topbar-item">home</a></div></div><div id="content">`,
},
{
desc: "Unappealable Temp Ban Header Test",
data: map[string]any{
"ban": &gcsql.IPBan{
RangeStart: "192.168.56.0",
RangeEnd: "192.168.56.255",
IPBanBase: gcsql.IPBanBase{
StaffID: 1,
Message: "ban message goes here",
},
},
"ip": "192.168.56.1",
"siteConfig": testingSiteConfig,
"systemCritical": config.SystemCriticalConfig{
WebRoot: "/",
},
"boardConfig": config.BoardConfig{
DefaultStyle: "pipes.css",
},
},
expectedOutput: `<!DOCTYPE html><html lang="en"><head>` +
`<meta charset="UTF-8"><meta name="viewport"content="width=device-width, initial-scale=1.0">` +
`<title>YOU ARE BANNED:(</title><link rel="stylesheet"href="/css/global.css"/>` +
`<link id="theme"rel="stylesheet"href="/css/pipes.css"/><link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script><script type="text/javascript"src="/js/gochan.js"defer></script>` +
`</head><body><div id="topbar"><div class="topbar-section"><a href="/"class="topbar-item">home</a></div></div><div id="content">`,
},
}
)
const (
boardPageHeaderBase = `<!DOCTYPE html><html lang="en"><head>` +
`<meta charset="UTF-8"><meta name="viewport"content="width=device-width, initial-scale=1.0">` +
`<title>/test/-Testing board</title>` +
`<link rel="stylesheet"href="/css/global.css"/><link id="theme"rel="stylesheet"href="/css/pipes.css"/>` +
`<link rel="shortcut icon"href="/favicon.png">` +
`<script type="text/javascript"src="/js/consts.js"></script><script type="text/javascript"src="/js/gochan.js"defer></script></head>` +
`<body><div id="topbar"><div class="topbar-section"><a href="/"class="topbar-item">home</a></div>` +
`<div class="topbar-section"><a href="/test/"class="topbar-item"title="Testing board">/test/</a><a href="/test2/" class="topbar-item" title="Testing board#2">/test2/</a></div></div>` +
`<div id="content"><header><h1 id="board-title">/test/-Testing board</h1><div id="board-subtitle">Board for testing<br/><a href="/test/catalog.html">Catalog</a> | <a href="#footer">Bottom</a></div></header><hr />` +
`<div id="postbox-area"><form id="postform"name="postform"action="/post"method="POST"enctype="multipart/form-data">` +
`<input type="hidden"name="threadid"value="0"/><input type="hidden"name="boardid"value="1"/>` +
`<table id="postbox-static"><tr><th class="postblock">Name</th><td><input type="text" name="postname" maxlength="100" size="25" /></td></tr>` +
`<tr><th class="postblock">Email</th><td><input type="text" name="postemail" maxlength="100" size="25" /></td></tr>` +
`<tr><th class="postblock">Subject</th><td><input type="text"name="postsubject"size="25"maxlength="100"><input type="text"name="username"style="display:none"/><input type="submit"value="Post"/></td></tr>` +
`<tr><th class="postblock">Message</th><td><textarea rows="5" cols="35" name="postmsg" id="postmsg"></textarea></td></tr>` +
`<tr><th class="postblock">File</th><td><input name="imagefile" type="file" accept="image/jpeg,image/png,image/gif,video/webm,video/mp4">` +
`<label for="spoiler"><input type="checkbox" id="spoiler" name="spoiler"/>Spoiler</label></td></tr>` +
`<tr id="threadoptions"style="display: none;"><th class="postblock">Options</th><td></td></tr>` +
`<tr><th class="postblock">Password</th><td><input type="password" id="postpassword" name="postpassword" size="14" />(for post/file deletion)</td></tr></table>` +
`<input type="password" name="dummy2" style="display:none"/></form></div><hr />`
)
type templateTestCase struct {
desc string
data any
expectsError bool
expectedOutput string
desc string
data any
expectsError bool
getDefaultStyle bool
validationFunc func(t *testing.T, reader io.Reader)
}
func (tC *templateTestCase) Run(t *testing.T, templateName string) {
buf := new(bytes.Buffer)
err := serverutil.MinifyTemplate(templateName, tC.data, buf, "text/javascript")
var buf bytes.Buffer
err := serverutil.MinifyTemplate(templateName, tC.data, &buf, "text/javascript")
if tC.expectsError {
assert.Error(t, err)
} else {
if !assert.NoError(t, err) {
// var allStaff []gcsql.Staff
return
t.FailNow()
}
if assert.NotNilf(t, tC.validationFunc, "Validation function for %q is not implemented", tC.desc) {
tC.validationFunc(t, &buf)
}
assert.Equal(t, tC.expectedOutput, buf.String())
}
}

View file

@ -28,11 +28,10 @@ const (
`ORDER BY\s+position ASC,\s*name ASC`
)
func initTemplatesMock(t *testing.T, mock sqlmock.Sqlmock, which ...string) bool {
t.Helper()
func initTemplatesMock(t *testing.T, mock sqlmock.Sqlmock, which ...string) {
_, err := testutil.GoToGochanRoot(t)
if !assert.NoError(t, err) {
return false
t.FailNow()
}
rows := sqlmock.NewRows([]string{"boards.id", "section_id", "uri", "dir", "navbar_position", "title",
@ -55,14 +54,17 @@ func initTemplatesMock(t *testing.T, mock sqlmock.Sqlmock, which ...string) bool
config.SetTestTemplateDir("templates")
if !assert.NoError(t, gctemplates.InitTemplates(which...)) {
return false
if !assert.NoError(t, gctemplates.InitTemplates()) {
t.FailNow()
}
return assert.NoError(t, mock.ExpectationsWereMet())
if !assert.NoError(t, mock.ExpectationsWereMet()) {
t.FailNow()
}
}
func runTemplateTestCases(t *testing.T, templateName string, testCases []templateTestCase) {
t.Helper()
db, mock, err := sqlmock.New()
if !assert.NoError(t, err) {
return
@ -73,12 +75,14 @@ func runTemplateTestCases(t *testing.T, templateName string, testCases []templat
return
}
if !initTemplatesMock(t, mock) {
return
}
initTemplatesMock(t, mock, templateName)
serverutil.InitMinifier()
for _, tC := range testCases {
if tC.getDefaultStyle {
mock.ExpectPrepare(`SELECT default_style FROM boards WHERE dir = \?`).ExpectQuery().
WithArgs("test").WillReturnRows(sqlmock.NewRows([]string{"default_style"}).AddRow("pipes.css"))
}
t.Run(tC.desc, func(t *testing.T) {
tC.Run(t, templateName)
})
@ -98,16 +102,7 @@ func TestJsConstsTemplate(t *testing.T) {
}
func TestTemplateBase(t *testing.T) {
db, mock, err := sqlmock.New()
if !assert.NoError(t, err) {
return
}
config.SetTestDBConfig("mysql", "localhost", "gochan", "gochan", "gochan", "")
if !assert.NoError(t, gcsql.SetTestingDB("mysql", "gochan", "", db)) {
return
}
initTemplatesMock(t, mock)
runTemplateTestCases(t, "", nil)
}
func TestBaseFooter(t *testing.T) {

View file

@ -423,6 +423,13 @@ func MakePost(writer http.ResponseWriter, request *http.Request) {
return
}
isSpoileredThread := request.PostFormValue("spoilerthread") == "on"
if isSpoileredThread && !boardConfig.EnableSpoileredThreads {
writer.WriteHeader(http.StatusBadRequest)
server.ServeError(writer, "Board does not support spoilered threads", wantsJSON, nil)
return
}
var delay int
var tooSoon bool
if post.ThreadID == 0 {
@ -433,6 +440,10 @@ func MakePost(writer http.ResponseWriter, request *http.Request) {
// replying to a thread
delay, err = gcsql.SinceLastPost(post.IP)
tooSoon = delay < boardConfig.Cooldowns.Reply
if isSpoileredThread {
warnEv.Msg("User submitted a form with spoilered thread enabled while replying to a thread")
server.ServeError(writer, server.NewServerError("Invalid request", http.StatusBadRequest), wantsJSON, nil)
}
}
if err != nil {
errEv.Err(err).Caller().Str("boardDir", board.Dir).Msg("Unable to check post cooldown")
@ -526,7 +537,15 @@ func MakePost(writer http.ResponseWriter, request *http.Request) {
return
}
_, emailCommand := getEmailAndCommand(request)
if err = post.Insert(emailCommand != "sage", board.ID, isLocked, isSticky, false, isCyclic); err != nil {
thread := &gcsql.Thread{
BoardID: board.ID,
Locked: isLocked,
Stickied: isSticky,
Anchored: emailCommand == "sage" && post.ThreadID == 0,
}
if err = post.Insert(emailCommand != "sage", thread, false); err != nil {
errEv.Err(err).Caller().
Str("sql", "postInsertion").
Msg("Unable to insert post")

View file

@ -36,7 +36,7 @@ coalesce(f.thumbnail_width, 0) AS tw,
coalesce(f.thumbnail_height, 0) AS th,
coalesce(f.width, 0) AS width,
coalesce(f.height, 0) AS height,
t.locked, t.stickied, t.cyclical, flag, country, p.is_deleted
t.locked, t.stickied, t.cyclical, t.spoilered, flag, country, p.is_deleted
FROM DBPREFIXposts p
LEFT JOIN DBPREFIXfiles f ON f.post_id = p.id AND p.is_deleted = FALSE
LEFT JOIN DBPREFIXthreads t ON t.id = p.thread_id

View file

@ -41,9 +41,12 @@
<tr id="threadoptions" {{if $noCyclicThreads}}style="display: none;"{{end}}>
<th class="postblock">Options</th>
<td>
{{if not $noCyclicThreads}}
<label for="cyclic"><input type="checkbox" name="cyclic" id="cyclic"> Cyclic thread</label>
{{end}}
{{- if not $noCyclicThreads -}}
<label for="cyclic"><input type="checkbox" name="cyclic" id="cyclic"> Cyclic thread</label>
{{- end -}}
{{- if $.boardConfig.EnableSpoileredThreads -}}
<label for="spoilerthread"><input type="checkbox" name="spoilerthread" id="spoilerthread"> Spoiler thread</label>
{{- end -}}
</td>
</tr>{{end}}
<tr><th class="postblock">Password</th><td><input type="password" id="postpassword" name="postpassword" size="14" /> (for post/file deletion)</td></tr>